Compare commits
1799 Commits
71386f4ef2
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| c1efa7b94e | |||
| e31b84c08e | |||
| 258e3ec620 | |||
| 3336abb66f | |||
| a184ee0221 | |||
| 4509a2b6d3 | |||
| 7e38baa7b6 | |||
| aab7e5ae20 | |||
| a0fcdf74da | |||
| ebc782b16c | |||
| 7939dc92d4 | |||
| 7c06b27c73 | |||
| 02b2ce8109 | |||
| 26f998d243 | |||
| f816049fdf | |||
| eafa353364 | |||
| 353bb59393 | |||
| 1daa8aa9b1 | |||
| 5af024f7e7 | |||
| 84ce9843ff | |||
| efcee88f05 | |||
| 0b307037e0 | |||
| 67bd05fc96 | |||
| dd6b0bccb3 | |||
| a50d3e7ca7 | |||
| d3d2f9a448 | |||
| 98ad5674a8 | |||
| 30d0331174 | |||
| 24662fa994 | |||
| 230ef8ed7c | |||
| 160c09e525 | |||
| 589d45e0a0 | |||
| acd355bb5a | |||
| 6e59395fb8 | |||
| 9f4516c6a8 | |||
| 0bd2273bee | |||
| d37fa1584c | |||
| e17cb09269 | |||
| 4d55e45962 | |||
| e3532064b5 | |||
| 1e37b20c6a | |||
| 4f03775e04 | |||
| 9678b02aba | |||
| a926487f5e | |||
| ae1d30bc5a | |||
| a7d145aa70 | |||
| 472d4ba008 | |||
| 2a0478cad8 | |||
| cee0c591e2 | |||
| 68b6ffffd7 | |||
| 9bc8c4b47f | |||
| e80ebd35cb | |||
| 36343baecc | |||
| 89cf171efc | |||
| 149ec8e4e4 | |||
| d1cd963e4b | |||
| 5ef0a1fd3e | |||
| 6ace96f2cf | |||
| 2d71f2ce30 | |||
| 2c3dba55e6 | |||
| c7a04dcc70 | |||
| 4b14c15518 | |||
| c68ef346bf | |||
| c5d7fcc303 | |||
| 9bf56d5748 | |||
| d5ce56930b | |||
| 349194e7e5 | |||
| 24d6460e4c | |||
| 127e783f66 | |||
| 198fd12bb2 | |||
| 34d5209165 | |||
| 9684ab75bb | |||
| 0a6b035a67 | |||
| cbfd3e5632 | |||
| 3faf0866a0 | |||
| bab3a160c2 | |||
| 1778cd0009 | |||
| 5204766276 | |||
| 6218012d3f | |||
| ccb0c1d18e | |||
| 65e24bd446 | |||
| de6cecaffc | |||
| da545ba9b9 | |||
| 3c4842df1e | |||
| 1ee0f0b57a | |||
| 4fbbd9680b | |||
| 259a5a2b3e | |||
| 8d62be9eff | |||
| 63139350e4 | |||
| 33b33e685a | |||
| a8038bb534 | |||
| 4d0e34c4cf | |||
| 70ffd252bd | |||
| 51d468fbcc | |||
| 1c84556600 | |||
| 34997bcbd1 | |||
| 78cb2acd6c | |||
| ce8a03ab16 | |||
| 19feca4964 | |||
| adbda094e7 | |||
| 7013da70bc | |||
| 49d9410e3a | |||
| 84a2e7a93e | |||
| 950b8a8128 | |||
| af58f7a32c | |||
| 91c6f2f091 | |||
| 31cf353463 | |||
| 8912423aeb | |||
| bc85cd4984 | |||
| fc8eb70617 | |||
| 1a5896ef84 | |||
| 7b94eeaa60 | |||
| 50076962f6 | |||
| d39aef0aac | |||
| 9f533b1077 | |||
| fdaba40ba9 | |||
| caf6318a5d | |||
| 23649d85b0 | |||
| c67aed01dc | |||
| 66cc51d6d0 | |||
| 4a87588435 | |||
| c0fd372529 | |||
| 203568c967 | |||
| 0394fce929 | |||
| d2946c00ce | |||
| b7e1f89c1d | |||
| c0f9867218 | |||
| 69515e8e81 | |||
| 70b8d03c02 | |||
| f8baf3f258 | |||
| b76f1a6009 | |||
| 7841d1dc87 | |||
| ee421a71e2 | |||
| e37971456a | |||
| 79f8fabb1b | |||
| dfd2c9c49e | |||
| 5470e25bb0 | |||
| 374d6dc396 | |||
| d0715774a8 | |||
| 6f544e2b1f | |||
| e713d47319 | |||
| b361d43088 | |||
| a33d28a7ae | |||
| 4a4dede105 | |||
| b818d3fc5a | |||
| cf839e7345 | |||
| c54cb126ff | |||
| 8dc4c4d072 | |||
| 9742eaea28 | |||
| fb66c0ed90 | |||
| 9deeef6e8d | |||
| e2b957b6bd | |||
| abf15391f6 | |||
| 44e36f7dd2 | |||
| a77c4b6db5 | |||
| cb3d2c40e5 | |||
| f50e14d7a5 | |||
| 0ead519a80 | |||
| 7d98b49a30 | |||
| f054abfbd2 | |||
| 2b5c6fd606 | |||
| ffa490e767 | |||
| 8ac42cdbad | |||
| 1b4c6cab6d | |||
| 176d5d0bb7 | |||
| 3df95adc52 | |||
| a6bf4eb7e7 | |||
| baa12823f7 | |||
| 8c711f5f4a | |||
| c4f00ed483 | |||
| f5c301d5c6 | |||
| c395f7d16e | |||
| 26f900870b | |||
| bb99ad5611 | |||
| 6c58e25211 | |||
| b24ab838f8 | |||
| cf7c66b99a | |||
| 04b56ffacd | |||
| abb7f743b8 | |||
| 14cfa021c5 | |||
| 86272b6b08 | |||
| 89a2321dd4 | |||
| 6634b2b8a2 | |||
| b65e82a475 | |||
| b006f9804a | |||
| 5b27587f17 | |||
| 5d5f5f4516 | |||
| 938ead79f7 | |||
| 4a401cf816 | |||
| 5deed79b42 | |||
| 762e99a907 | |||
| f9edd2023d | |||
| 30101c83e8 | |||
| 10f6544e2e | |||
| 9c690fbdfb | |||
| 6f9bdc4d50 | |||
| 7f329e3b31 | |||
| 97d808585a | |||
| 4bb7c1ffb5 | |||
| 388a934665 | |||
| 99e6a456a7 | |||
| a5fe358313 | |||
| d7d7b59866 | |||
| 362ccff85d | |||
| 6ec0ab78d9 | |||
| e9a970a75b | |||
| 2a545b8b3e | |||
| bf1308dd55 | |||
| ca09e8e6ca | |||
| 6db07f1371 | |||
| 107921e0d0 | |||
| 053b364a44 | |||
| 3282832a4a | |||
| 00524bebe0 | |||
| 9df4d2d7ee | |||
| 2c5f0b8b28 | |||
| 702e2e00eb | |||
| 2b1c3256b6 | |||
| 6a57c13c56 | |||
| 362f4943d4 | |||
| f15c4caf97 | |||
| aa48c9ef8a | |||
| 3df9c4d9e6 | |||
| 2178295eaa | |||
| 055dcec65b | |||
| 9a24feb914 | |||
| 46567555e1 | |||
| b41bfd35c0 | |||
| 6a83e67f95 | |||
| 469b9aa9c6 | |||
| 77a29ed3c6 | |||
| a30a3d3a47 | |||
| a9787ef041 | |||
| fcf16fd654 | |||
| 0a14ec63de | |||
| 5469740f4c | |||
| 891f2daf99 | |||
| b7daabe2e0 | |||
| d78f81c3a7 | |||
| 170d22eebb | |||
| c8ff7b0718 | |||
| bafd9cbe75 | |||
| f11b308f91 | |||
| 81e1a25de6 | |||
| 8ff2f33d3a | |||
| ea15db430c | |||
| 7e4178f7e2 | |||
| 80fd8863c9 | |||
| cf2810b35e | |||
| 4916efa925 | |||
| e89b8f7d12 | |||
| 9bc1e7e9ff | |||
| c05a6be6f2 | |||
| f7f4a41d61 | |||
| 81327678b1 | |||
| bad1fb609a | |||
| bef267257a | |||
| 909aa430b8 | |||
| 0b99d85244 | |||
| 21bbf4bee0 | |||
| e5e0b96861 | |||
| 02d1001583 | |||
| 64468dfb1b | |||
| 2864a5e4b8 | |||
| 735bc15011 | |||
| 341fedd932 | |||
| d186d31399 | |||
| 098684973e | |||
| b107109453 | |||
| a33e8db9a3 | |||
| fb76e3ecb4 | |||
| 3d79293167 | |||
| 74745edcda | |||
| 0812131a97 | |||
| 1068bba5c7 | |||
| 1b5e58a3b4 | |||
| acae043f31 | |||
| b4299f8f37 | |||
| b6adac6714 | |||
| 1c8f203164 | |||
| 0c30ece281 | |||
| 4e559e56d4 | |||
| 19f28b40ac | |||
| bcaf43a540 | |||
| 9c7b635e7e | |||
| 65c87dff3a | |||
| 132a76df27 | |||
| b0954eeddc | |||
| 8f1add6059 | |||
| 8a78c9699e | |||
| 0721b29a2c | |||
| 3d354909d6 | |||
| 7570a84dfd | |||
| 6a05ff5840 | |||
| 919fe8381b | |||
| b76ad3caaf | |||
| 409d45857d | |||
| 0d1566977a | |||
| 0cbfbab5ad | |||
| 37e0c2aaac | |||
| 296249de32 | |||
| 4449e7c6e8 | |||
| 2eb5a9a616 | |||
| d679e68501 | |||
| bc6caddcc8 | |||
| 55e8306576 | |||
| 7953ec80e5 | |||
| c6bb4915bc | |||
| b050cd01f9 | |||
| 730670cf52 | |||
| c5c8703699 | |||
| 2bd1570d6b | |||
| 68b6a09697 | |||
| 7b52c921d5 | |||
| bb8b9ab6da | |||
| 971f312b46 | |||
| e0d5c63dc5 | |||
| 85fcbd84fe | |||
| 221bc04754 | |||
| 6347640a35 | |||
| f2d8ad0b6b | |||
| 739786d9ab | |||
| f642809939 | |||
| 02106a99b9 | |||
| df3a3ba789 | |||
| cd80d4c9e8 | |||
| dab44edef2 | |||
| ed0ad61bc4 | |||
| b2cb717178 | |||
| 7a9f6d2223 | |||
| a9022184fc | |||
| 826b3c2997 | |||
| 2e6c5f7c04 | |||
| 2d6730de56 | |||
| b6cc0e3077 | |||
| 91c8731940 | |||
| 1f03891b25 | |||
| 9ff15b8b03 | |||
| 170f5cd473 | |||
| 29ec172c8b | |||
| 0f220f50d6 | |||
| d866c1b903 | |||
| fbde1a2030 | |||
| 4ba7b9162d | |||
| 9d49418a1f | |||
| 3522751a15 | |||
| 074c555294 | |||
| 206a927f30 | |||
| fd37dfe3f9 | |||
| 1ce6ca2b07 | |||
| 83e5125b37 | |||
| ca82aa283a | |||
| 8ce33ee6ff | |||
| 073a9f5786 | |||
| 655c1c9aff | |||
| 17d4bceb42 | |||
| 0f61f2f328 | |||
| c88cb4bca9 | |||
| 46c02b89de | |||
| e13d97aa98 | |||
| 958ae8945d | |||
| f55a3764d5 | |||
| 3bdcf37bf0 | |||
| 9d7808ec46 | |||
| 20d30903fd | |||
| b78f6f23b5 | |||
| 867a47218a | |||
| afc251aa7c | |||
| 31efbf73b7 | |||
| 31c6d13fdf | |||
| b3497d9ed6 | |||
| 7f40605bfe | |||
| c571c93f61 | |||
| 90ca8ca2c5 | |||
| 399b1a373e | |||
| c1274e851a | |||
| 40d0576b15 | |||
| b4266c26b0 | |||
| 23aa5c6f94 | |||
| 4056cbb11c | |||
| 13cdcbcdb1 | |||
| c881b59957 | |||
| 78a0d11f24 | |||
| 09b88d164f | |||
| 789da641c1 | |||
| abd713d693 | |||
| 802357b7a0 | |||
| c5d4530947 | |||
| 367397fdd4 | |||
| 63fa60e7f4 | |||
| 544a06964d | |||
| 50583f9474 | |||
| 1ad7fe8deb | |||
| 752a19a4e7 | |||
| 76ac4e2987 | |||
| f82cfead46 | |||
| 4d1ae4eafd | |||
| a41dee4a55 | |||
| 13961d501f | |||
| e6f14e79da | |||
| 1ff09d0fc1 | |||
| f2d25c8d6c | |||
| fe4fb4b4f7 | |||
| faa952295f | |||
| e9798a22c3 | |||
| 34dd64103c | |||
| 6a27720709 | |||
| ccf10fc20c | |||
| 31942b1114 | |||
| d8d4714370 | |||
| 9183fd66b2 | |||
| 67b05eeb09 | |||
| 7d4b0dd133 | |||
| 9073dee986 | |||
| 3cdb5c2fe6 | |||
| acc7d4ff56 | |||
| 50cc78788f | |||
| c462a3b8d5 | |||
| c30c142653 | |||
| fbd7e0a14b | |||
| 6b81401e2d | |||
| c757b8967f | |||
| d0a7ef31bc | |||
| 3fd8a18157 | |||
| 54ba1096d7 | |||
| 87fc490c3b | |||
| ebe5beba1d | |||
| 77ab37f637 | |||
| 461e730c34 | |||
| 05e83eabef | |||
| ba72925d53 | |||
| 87ce209050 | |||
| 3ed8260877 | |||
| 44347db6e4 | |||
| 91632aa193 | |||
| e6f4eeca8e | |||
| a23279e633 | |||
| 83057ebbd4 | |||
| c51ba9670e | |||
| 59a007419f | |||
| 206ed33516 | |||
| 0d27bde33e | |||
| df391968d8 | |||
| 5964eee833 | |||
| 387ce9c462 | |||
| 6ddcf2cb02 | |||
| 87e97eab88 | |||
| 13f1d53191 | |||
| 225894d327 | |||
| c14333c540 | |||
| 405d1f6789 | |||
| ff6d0b8f9b | |||
| d141c02074 | |||
| 12ae94cd60 | |||
| 82805dcfdd | |||
| 5c39a36c12 | |||
| 4aed4d7472 | |||
| 649f70332b | |||
| 08e975cd8e | |||
| 7d54eef95b | |||
| 1361c1d5de | |||
| ea48092270 | |||
| 324ed776c9 | |||
| 7af89da092 | |||
| 002223e149 | |||
| 90aecb8d7a | |||
| 3e39dd25af | |||
| 71bfc96b5c | |||
| 074a5e855d | |||
| c16e060f73 | |||
| f688e2d1ae | |||
| 286983c833 | |||
| 00f3df8719 | |||
| d8009978e5 | |||
| 9bb30fbd92 | |||
| 82688c2e13 | |||
| a02d7162d9 | |||
| e39cc32df9 | |||
| 6017c0a2fc | |||
| 78fa6e3925 | |||
| 5d00383d71 | |||
| 19cf700d61 | |||
| 0c5ff65639 | |||
| 1206ffced2 | |||
| 5fbd0c13db | |||
| 36a8ce5561 | |||
| dbadbe34b3 | |||
| 2b8b0dcffd | |||
| b7e5e0db3e | |||
| 5c94471956 | |||
| ccfe30cd68 | |||
| 2c7038cd1f | |||
| d70ca86d7c | |||
| 8d95758ed7 | |||
| dd4c1a94e6 | |||
| 7c6ab366af | |||
| f121cc0a24 | |||
| 7456c152b7 | |||
| b63868bbb5 | |||
| 59e8d66255 | |||
| 1b200eb676 | |||
| 3ada21a1df | |||
| 9fe67da98b | |||
| d8d4bce287 | |||
| b3979b31c7 | |||
| 2e0c7c4406 | |||
| f73dc05e25 | |||
| 5844209bee | |||
| ff334c1c3d | |||
| 4374a89535 | |||
| 31dcad6220 | |||
| a58cffe12b | |||
| de7724dfcf | |||
| 4edc4bad02 | |||
| 2444a60a3e | |||
| ae88480d0a | |||
| 2ed3f877c3 | |||
| 09d85d6c31 | |||
| 999bb7aca1 | |||
| b84f975f83 | |||
| b12228ee95 | |||
| 5460e4922b | |||
| 7b4f684a25 | |||
| 56b754153a | |||
| f5d68fcc22 | |||
| 42e6e6355d | |||
| 8e4475bb56 | |||
| d5766b58fe | |||
| b524778039 | |||
| 45c2a22340 | |||
| 1b01f3e8ba | |||
| 2760f4cab9 | |||
| f3e57715a3 | |||
| 5cef90fad9 | |||
| 0764143d2c | |||
| e0e84f3756 | |||
| 69268187c2 | |||
| 02437a6a20 | |||
| 3c5afaf33a | |||
| 396bf50239 | |||
| 24d35a5817 | |||
| b8d9c4bdd6 | |||
| 35f0e400ad | |||
| 00d5553bcb | |||
| a142630ff9 | |||
| 492a149c7f | |||
| c110e64341 | |||
| 0e51e19cab | |||
| 35b0b1ea42 | |||
| cca8b5f2b2 | |||
| 48265c4227 | |||
| c38efdfbce | |||
| d8833a310d | |||
| 5824d7c716 | |||
| 6e191d3c79 | |||
| 21164a9b61 | |||
| 4923b17ad6 | |||
| c75e903619 | |||
| 042cbc4453 | |||
| 03cc25eec0 | |||
| f2c31d29a2 | |||
| 5482f8e72e | |||
| 96df140153 | |||
| 4dfce32730 | |||
| 388f606ad2 | |||
| 09444f9e08 | |||
| c6a8fb1117 | |||
| 043012e809 | |||
| 5c9ee1a988 | |||
| 22b7f6dd7d | |||
| bdba0332e1 | |||
| 16be69c104 | |||
| 830d05e217 | |||
| 7e7bee8f48 | |||
| ac1797344c | |||
| b4ce8a7cab | |||
| e68c56b334 | |||
| cabfdd47b5 | |||
| cfe893f358 | |||
| 581211f13e | |||
| 8ed78d48fb | |||
| 96222de5bc | |||
| 681287c46a | |||
| 9cb5c70d51 | |||
| c62050445b | |||
| a8f5a6c2f4 | |||
| e54bb2e423 | |||
| 5058136737 | |||
| 74dc76e22e | |||
| 44161c4157 | |||
| e8d04c0603 | |||
| 96415a8d2a | |||
| 2157f9a322 | |||
| b387370aaf | |||
| 3110505b21 | |||
| da536c8c3f | |||
| 98a378ad8a | |||
| ab73225f00 | |||
| cc4c222975 | |||
| a32c8bf228 | |||
| e6d6b0349e | |||
| e2228a18c1 | |||
| e046c59f7c | |||
| fbe27d69c0 | |||
| 021a2c0e2e | |||
| c243b6104c | |||
| a1a822c5b6 | |||
| c4abe39375 | |||
| c52c4f7d32 | |||
| 653ddd9f11 | |||
| e854b88394 | |||
| 66478143df | |||
| 4b461f87ff | |||
| fc2b5744f4 | |||
| 65ad070878 | |||
| f1668999a5 | |||
| 9db81d1913 | |||
| 7c795b800d | |||
| e058a9ae6c | |||
| 4f09e6bbb5 | |||
| 60e022035f | |||
| 7a3e8dba92 | |||
| c4615bd256 | |||
| b6157707db | |||
| 09a0a2d7da | |||
| 9db4b3a9c2 | |||
| 6987332ba8 | |||
| 4c76a7fd18 | |||
| 2b7d825694 | |||
| 07bfa0cf10 | |||
| e15b16b19b | |||
| 76d60b0958 | |||
| 97d02fd7c8 | |||
| 5817186129 | |||
| 2d6dd3b0b2 | |||
| cd5d8e1c20 | |||
| fe2332ee87 | |||
| 215537a261 | |||
| ec65b98874 | |||
| f1c4a38a49 | |||
| 5259f11679 | |||
| 565a6563e1 | |||
| 8267990e6f | |||
| e8020acabf | |||
| e5b980fbc7 | |||
| b803ce99e3 | |||
| 3ae1e58ff2 | |||
| ce347a0ff4 | |||
| d3f97ef93e | |||
| 53cd08f0da | |||
| da5ebf7ab3 | |||
| ca3535b1a5 | |||
| 2c1e51a8b8 | |||
| 71b2859440 | |||
| 1d799185d6 | |||
| b97f410731 | |||
| a18c2e5be1 | |||
| 3025133d18 | |||
| 743e916d12 | |||
| 8c5a1d15cb | |||
| 372d4d5c34 | |||
| b0796f72d3 | |||
| 689adde8ae | |||
| 983d533452 | |||
| ef2733df48 | |||
| 20db27fa7e | |||
| bb88eb7154 | |||
| 2a1bf4a42a | |||
| 2889a72b81 | |||
| 9ecb233763 | |||
| 1db0a9eaa8 | |||
| 687ad8d0f0 | |||
| c3f564605f | |||
| c854c7f9d2 | |||
| 3713125f57 | |||
| 9f9173c691 | |||
| a98903a85b | |||
| a2cbe79787 | |||
| 3cef074c9e | |||
| b24f858369 | |||
| 72bb5b42af | |||
| f53bb28b66 | |||
| 2957a45c4b | |||
| c7e5c1fce8 | |||
| 8731f58948 | |||
| 7b64258af6 | |||
| 122ff2d216 | |||
| c0abb0d50d | |||
| 1ff312d236 | |||
| b80f801d23 | |||
| 9fcd1a0d23 | |||
| 9200e22a7e | |||
| d5ff55e23e | |||
| 5dc613cd79 | |||
| 03af183fb3 | |||
| 144cf71368 | |||
| 5eafa37cdd | |||
| 1d86c6da01 | |||
| a2692e1469 | |||
| ed3d14b131 | |||
| 50429a3513 | |||
| b4e1ced3ed | |||
| b92b281050 | |||
| c980fddfa1 | |||
| 613e6d6503 | |||
| 4d0b6b93bc | |||
| f5bcc9b851 | |||
| 152576e85d | |||
| 609b132106 | |||
| d0f2a865bc | |||
| 5940cf24a0 | |||
| 60b5b5d312 | |||
| bffd27ae5b | |||
| 13573f4b3f | |||
| 1bdb7f4e3a | |||
| f9b895b32c | |||
| 3a95d0da01 | |||
| fcd7723f73 | |||
| 47f6c44c17 | |||
| 34b2901566 | |||
| 1adee07127 | |||
| 3c60976efa | |||
| 053b801262 | |||
| 1a37fd0ca4 | |||
| f14d70ea35 | |||
| b6283b3469 | |||
| b19e248383 | |||
| c07905c360 | |||
| da32d0d9e7 | |||
| 4a6c53703f | |||
| 4c84673bdf | |||
| 715f2bc907 | |||
| b78d568d9f | |||
| 3b1e3ea62c | |||
| e65dd33084 | |||
| bec78e84e6 | |||
| e6a343c7ec | |||
| f05dccd384 | |||
| 41f67cabc0 | |||
| bc5e7445d9 | |||
| 2883b4c35b | |||
| 6d199244ef | |||
| 1c27a29238 | |||
| bd64f7bd86 | |||
| a07d954f1c | |||
| 511c8ea79d | |||
| db33707e5e | |||
| ed5431680f | |||
| f1fcde2142 | |||
| 9f2fb716f7 | |||
| 14b4969a65 | |||
| 15feac81c9 | |||
| 2bbf0d1b82 | |||
| 0b06bed1db | |||
| 2055d7a07f | |||
| da92ce3a46 | |||
| dcad1840c4 | |||
| f6694031a1 | |||
| cfddaaae13 | |||
| 8524472d38 | |||
| 863612d1a1 | |||
| e446fc47ce | |||
| e33a887055 | |||
| 78951a3bc4 | |||
| 44318d1ecd | |||
| a6fb44e1ab | |||
| 374d2d1962 | |||
| 3ad143228d | |||
| 4ea14c853e | |||
| 38bbc1c6f5 | |||
| 9a34daa2bc | |||
| e5e3f5f0a3 | |||
| 899a89cb3b | |||
| 63cb564818 | |||
| e93511fde7 | |||
| eeac5c6c45 | |||
| f7b48333df | |||
| cb88a6fd9c | |||
| 7b95546b80 | |||
| 5f9ea6c06a | |||
| d54d1d964d | |||
| 868f271588 | |||
| 7606c9d325 | |||
| 3c66a6a1c1 | |||
| bf900e7468 | |||
| 52cbcb9128 | |||
| 97e7ba25a6 | |||
| bc9ff23372 | |||
| c176ed8861 | |||
| 63c0653006 | |||
| fbf899465d | |||
| 603e478794 | |||
| 347d23641b | |||
| c4e973e83b | |||
| d22abe300b | |||
| 2bab3119b0 | |||
| ea3d7b8892 | |||
| 7eb2f78174 | |||
| 7db04440ff | |||
| 062beb718d | |||
| b8f3cf1b47 | |||
| c280c1339b | |||
| 543acc9fff | |||
| 55d609c82b | |||
| f144c1cdbd | |||
| 1531535151 | |||
| 4fe4c8bb03 | |||
| 1302682900 | |||
| 62e0821be2 | |||
| 856e616247 | |||
| 71fdba3c4a | |||
| 7803bd5c06 | |||
| 8dc9e01439 | |||
| 1d6ce13a6c | |||
| 6a907b1542 | |||
| bef759e358 | |||
| 63023c5266 | |||
| b6f2f9998e | |||
| 6d9115f593 | |||
| 02f7d7c74d | |||
| eeb4ec0e73 | |||
| 9efa05d4de | |||
| 72a8483a23 | |||
| d4da49512c | |||
| 6437970f7f | |||
| bf79618221 | |||
| dbeac1f8eb | |||
| 1625b8a665 | |||
| 5ab202e5c9 | |||
| 2f4fbc9d49 | |||
| 613ffefeb3 | |||
| 89e93be091 | |||
| 9b42077f7a | |||
| 81b88477bd | |||
| 52376e10a5 | |||
| b8b3f9e7ec | |||
| 194eb30042 | |||
| b52aa9ac76 | |||
| c825c41aed | |||
| 6d8f704542 | |||
| 10a399f7e6 | |||
| d59a118e83 | |||
| 07570f9e8a | |||
| 90972e938e | |||
| 4297eb34c9 | |||
| 507f9c3c22 | |||
| 33551089ad | |||
| c8301c6ad6 | |||
| c3396780d7 | |||
| 8ea2b0b63b | |||
| e170d02bff | |||
| 1f872a9b0c | |||
| bcdc1d8915 | |||
| f577435e98 | |||
| bf94550a84 | |||
| a900b04254 | |||
| 3fe5a9d222 | |||
| a07a7676b7 | |||
| 593cc03962 | |||
| 820f255929 | |||
| 7db4146c8d | |||
| 91a6916f4c | |||
| 78374d0301 | |||
| 9185ee0cf9 | |||
| e2de51832c | |||
| 759f16d5b9 | |||
| d0ddedc2b2 | |||
| 4291005161 | |||
| ee144ccb2b | |||
| 7fea21f221 | |||
| 9a4b40f242 | |||
| c2d353b973 | |||
| 8b6cecdbf8 | |||
| 83d8f2821a | |||
| 0225204b4d | |||
| 478957803d | |||
| 0181cb26d2 | |||
| 2ddb3595c7 | |||
| 76e49d1bd0 | |||
| 91f1ee748e | |||
| 9bddf64510 | |||
| 2200ae143e | |||
| 80d9a2ca7d | |||
| cbd1bf35c6 | |||
| c1be57b205 | |||
| 4848bef0dd | |||
| a8f374dd43 | |||
| d43e41e1ba | |||
| 437ac4c59b | |||
| de218409ab | |||
| 060ed859d4 | |||
| 0783c43169 | |||
| b5a317e021 | |||
| 15c1f6dadf | |||
| efda9991f2 | |||
| 9fb651a04e | |||
| 55c652a02a | |||
| 2be706fb3f | |||
| 3e2cc8bfae | |||
| a6c339e13a | |||
| b0174f3acc | |||
| a343d9999e | |||
| 0a2cca6e40 | |||
| bca00f46a9 | |||
| 5848c02d50 | |||
| 29ddcfa1f9 | |||
| 831bb83f4e | |||
| b8a8babc88 | |||
| 4a35aa7272 | |||
| 2ded7d9b1f | |||
| 19f674cf5f | |||
| 790bf5eac3 | |||
| e1adc6a3bc | |||
| bcedab5113 | |||
| adca914d67 | |||
| 8583ab19f0 | |||
| 1aa16a43ee | |||
| e6661d3b0d | |||
| 678e0dc6ac | |||
| bdc10fb729 | |||
| 584fa87bbd | |||
| ac155bbf4c | |||
| 0f6f65045d | |||
| 843b9ddffd | |||
| e283d4cd21 | |||
| 4fe02d27c8 | |||
| 5a269c68ae | |||
| 570a1c85af | |||
| f7ff1ef2bd | |||
| 04530a801c | |||
| 756cd0921b | |||
| 292eee33b9 | |||
| 583c834ba8 | |||
| 33949dbdb1 | |||
| fd79ea4b9b | |||
| b3bff6b43f | |||
| 80aa55b706 | |||
| 76c16ce294 | |||
| 715e6648ae | |||
| 984fad811c | |||
| eafd2c8a13 | |||
| 82fb243ffe | |||
| 6cbf1b2eac | |||
| 043e411a98 | |||
| f93b666bbf | |||
| 7e28aa1474 | |||
| 1deef51df0 | |||
| 6f7934badc | |||
| 2f2680be3c | |||
| 4cd8f4a94c | |||
| a417980a81 | |||
| af69955801 | |||
| 214d49f1d9 | |||
| c4e36a1f97 | |||
| 5de6a1bea6 | |||
| 74a20a0e14 | |||
| 820d08017a | |||
| 258afec391 | |||
| 0cf5aac591 | |||
| 59fd34a4b4 | |||
| 1da3d252e8 | |||
| 3c1cc59d59 | |||
| 1692098d5d | |||
| fbab53af22 | |||
| ce1e263d57 | |||
| 9f99320fda | |||
| 20e1df43d0 | |||
| 728c5434bb | |||
| 542ac4f4e1 | |||
| d23fc228d7 | |||
| 4ff3e47d54 | |||
| 96b22eb557 | |||
| 9187107751 | |||
| c6812b5b11 | |||
| adb584623e | |||
| 120e8de9d1 | |||
| 21726b63f8 | |||
| 04f910ee03 | |||
| edace32213 | |||
| 5e527e434a | |||
| 1d90f7588b | |||
| f8b8a35152 | |||
| fa4c95a9b6 | |||
| a478fc4805 | |||
| febb28e9c4 | |||
| c78a39af50 | |||
| 21e6049c16 | |||
| 6e418337cc | |||
| 48f34053ab | |||
| abfe263750 | |||
| 9ba003b16d | |||
| 48793f3a95 | |||
| d8cf98fd64 | |||
| 78e12d5bee | |||
| bdb8bdf76c | |||
| 88b79eb3a5 | |||
| b6428197ac | |||
| a46138c8b9 | |||
| 1211ca277b | |||
| e6f395c643 | |||
| 1979646b4b | |||
| 5c0eb20cb4 | |||
| 009966a5c7 | |||
| 4427b3b291 | |||
| 3dda4d6540 | |||
| c9df0be874 | |||
| ca2627d3cf | |||
| 47e6527b0e | |||
| 7decbb6eef | |||
| 68da1d0551 | |||
| a6f21b6606 | |||
| 06a4e0c93b | |||
| 0ca1df24ed | |||
| 1d12a906d4 | |||
| 7bd7518963 | |||
| a9c5765be5 | |||
| 2292f63fb6 | |||
| db92b9f5ff | |||
| f538639882 | |||
| 56bc8c2890 | |||
| 1cba4d3fa7 | |||
| 4c7820ceac | |||
| 118dcd8fa0 | |||
| a142ade923 | |||
| 57ab10a87c | |||
| 8c1c3cd634 | |||
| 2d3634d6bf | |||
| 217f29f068 | |||
| 58c3eee153 | |||
| d9e1fb620b | |||
| a3f5b92484 | |||
| 8b96e0ab98 | |||
| eef2d451b7 | |||
| 371e66a6df | |||
| 0d12144744 | |||
| ba39724813 | |||
| af6e6bfc67 | |||
| 315b5a1048 | |||
| c410d4e9f5 | |||
| 299d976622 | |||
| e8587f99c9 | |||
| 63ab96b71b | |||
| e998438135 | |||
| 5fd7d64d21 | |||
| f05037c7d6 | |||
| d0fd654bf7 | |||
| 7165bd91cd | |||
| d3431a5d53 | |||
| fa6c865000 | |||
| fd680a93e0 | |||
| 38b604ad41 | |||
| 2ca67bb61a | |||
| 95b814b751 | |||
| 9963f3f988 | |||
| fde7d4a25a | |||
| 895b2c4f19 | |||
| 427ea9baab | |||
| df718e4498 | |||
| 00956f5bba | |||
| e48d216d79 | |||
| 489f178c7c | |||
| 3bd4eda789 | |||
| fc6c7b8dc6 | |||
| deef1f2c8a | |||
| 38bd38a487 | |||
| 40de64078a | |||
| 780bd5e65a | |||
| 2cd74b4ea9 | |||
| 0cd3df391e | |||
| 854d2b4c27 | |||
| 7227fc7c43 | |||
| 73dcb44121 | |||
| 54fd394ef5 | |||
| fda71166df | |||
| 69b6055353 | |||
| 1bdd0449e0 | |||
| a6fdf9010b | |||
| 941dae0625 | |||
| 4a715bfd17 | |||
| 0b70c7e490 | |||
| 0539836714 | |||
| c08b0e654b | |||
| b3cb48319a | |||
| 44553cc375 | |||
| fbe287a702 | |||
| 5863dcdf67 | |||
| f77bee25ef | |||
| c11328a064 | |||
| d04de2fba0 | |||
| d2b435618c | |||
| 7525bb78e5 | |||
| 2075a572fe | |||
| 73723ba6ba | |||
| 0791820a6c | |||
| 931f352873 | |||
| 7c7d2e0fa4 | |||
| 3372fb6f74 | |||
| bc856269ff | |||
| 06bae231ef | |||
| 65a0edc3a6 | |||
| b7c322d473 | |||
| 0776a04362 | |||
| e51fc5a585 | |||
| 3afc068a02 | |||
| 5cdad44abf | |||
| 43762df998 | |||
| 95228c6dd9 | |||
| 205fcf8487 | |||
| 336e8921ee | |||
| ef149b9fcf | |||
| 766b4c13c3 | |||
| f5605258e3 | |||
| 2ba4d2f2b7 | |||
| 2e050c066e | |||
| 3f83514427 | |||
| 8c227843c9 | |||
| ba084c0a10 | |||
| 3fdd42706d | |||
| b49b51a671 | |||
| e5bb386dd2 | |||
| 2867bb3bc3 | |||
| 416fd02069 | |||
| 581963cfb4 | |||
| 3b14543e66 | |||
| 5dd2194eba | |||
| 5b7880f817 | |||
| bafe1c5781 | |||
| d760be58c3 | |||
| 3da9b70632 | |||
| b7c5902f67 | |||
| 9a22b25564 | |||
| 44c3dec9dc | |||
| 87f3afd8fd | |||
| 53f1129242 | |||
| 74216f75e2 | |||
| ed8eca0c1d | |||
| dc8e6e53c7 | |||
| 989ab5a432 | |||
| ec26c03d58 | |||
| 3b1b3387e7 | |||
| 62c03d1334 | |||
| 51e12184d7 | |||
| 8c01eb9c00 | |||
| bf264d5add | |||
| 8b3bd38bad | |||
| ba0de8800a | |||
| cd5ae4cb7f | |||
| 6575542281 | |||
| bf2559da80 | |||
| fedc207de2 | |||
| 0d58478a73 | |||
| abf24d1942 | |||
| afe3f2f3f3 | |||
| 1d1cb567da | |||
| 699bbee544 | |||
| 9c54915e73 | |||
| ed4390b99d | |||
| 039d9bae68 | |||
| 93ab48ac9a | |||
| c95e312acb | |||
| 9279bc7060 | |||
| fe61576dcd | |||
| 3f6e3074f2 | |||
| 53a8e2aa57 | |||
| 44ab6f181c | |||
| 3a3a830706 | |||
| 1a6e3e73c5 | |||
| 7d508e5a7d | |||
| 370c224d3a | |||
| 7bce501069 | |||
| 9d15445eba | |||
| 16ee13f1f7 | |||
| 8d25eb0acd | |||
| 9cb13a91cd | |||
| 30f8930773 | |||
| 05eaa8d3e0 | |||
| a32ffdf6d4 | |||
| 0f97de1b09 | |||
| e8d6ccec9a | |||
| 005434f79b | |||
| fe997d8b01 | |||
| 7291932a0b | |||
| 49ade03a9a | |||
| dd6dbd25da | |||
| 50bf90fada | |||
| abb81b6390 | |||
| 36da3d3cba | |||
| ae71a99aa4 | |||
| a7034d6351 | |||
| 6010b4c252 | |||
| 8330f4fba9 | |||
| dc6e153b92 | |||
| a2655ee6a5 | |||
| 13248962af | |||
| b698982186 | |||
| 5a299b21c5 | |||
| bb90f11ec8 | |||
| 12369ba2ec | |||
| f5720bde14 | |||
| 0d59a4de48 | |||
| 73c74d0477 | |||
| 70ffd7ded8 | |||
| 92a3a8d6fa | |||
| 6e9cd02b2b | |||
| d0f90af251 | |||
| c53eb27c6f | |||
| 38773e89ff | |||
| 19cb30d360 | |||
| 50a734b977 | |||
| b988758ac2 | |||
| 2567328e8b | |||
| a7568fcbbf | |||
| 27a06ae90c | |||
| 211fd19031 | |||
| fe18611b4b | |||
| 5e9b45ad5f | |||
| af833daee4 | |||
| 1ad5317d6e | |||
| 7cf5df80ce | |||
| d6b880d110 | |||
| cf58a4376e | |||
| a76dcb289a | |||
| 48ec6224e7 | |||
| 127dd8baf4 | |||
| d25c3ff4fc | |||
| f0e9de4cf9 | |||
| 92607a788e | |||
| 82948c1f55 | |||
| 714929c72f | |||
| 98f0d384cb | |||
| e3c87ecf65 | |||
| 1fb9e98eeb | |||
| 01d7c8ec36 | |||
| 22d8d5a0b8 | |||
| 631ed997ba | |||
| 01930ab0cf | |||
| b8fe4c937e | |||
| a7a5b08ad8 | |||
| faaac72b81 | |||
| 8f41139076 | |||
| e1a67acde1 | |||
| 4ab2af51a5 | |||
| 9ffc4eaa40 | |||
| 5eda2581f4 | |||
| 21ceb4fdc4 | |||
| edcf43efba | |||
| eb1ef16b5e | |||
| 6b9c8b7a87 | |||
| a05b96e9a0 | |||
| 699f67aa75 | |||
| 76e469e444 | |||
| 8df630ee0c | |||
| b0c4c53880 | |||
| 116a9c9de0 | |||
| e63f8ab8b8 | |||
| 424245df18 | |||
| 094a11ec64 | |||
| 6b03c9c99f | |||
| 3d116af011 | |||
| cbd965d8e5 | |||
| c9d972fc12 | |||
| da9979494e | |||
| f5c907af33 | |||
| 906fc2dd3d | |||
| a62df536dd | |||
| 7db674b65d | |||
| 784deaa6ea | |||
| 8bc41c2c32 | |||
| 79afc7649d | |||
| 2eee3736df | |||
| a8ba38ef1e | |||
| d3ddbc0c72 | |||
| 6c4085398e | |||
| 2308578622 | |||
| 47cd87e653 | |||
| 87c9684c71 | |||
| 0b66d73178 | |||
| 24cd238eaf | |||
| 72bb9b80ad | |||
| 3d42ac8e44 | |||
| 205ab3b16d | |||
| bb25da274d | |||
| b56de35664 | |||
| 0655ac82f2 | |||
| 166dbfa491 | |||
| 9348977548 | |||
| 769c3eea9b | |||
| e7095af5b8 | |||
| fd42d72423 | |||
| 1382c455a2 | |||
| 1d7fbc841e | |||
| a8a168b0a7 | |||
| dbd1d02485 | |||
| 1c307daa03 | |||
| 7a31f84d34 | |||
| a2a4dd9862 | |||
| 3d8e0f6c23 | |||
| 146da2d072 | |||
| 6a9964e889 | |||
| 318e7c7458 | |||
| f8e2d27bb0 | |||
| 44544f3289 | |||
| 76a5c99b08 | |||
| 70f0f91a17 | |||
| f1394239a3 | |||
| 9e810502c2 | |||
| c67a1752c5 | |||
| d87e40ada5 | |||
| 86bbaa437e | |||
| 96b6b56d95 | |||
| 1f6e5e71ef | |||
| a55f1df17f | |||
| e654226e60 | |||
| 189dc93a6e | |||
| 76cb52878c | |||
| 8d3f0a9f4d | |||
| fe674ef2ea | |||
| 33b165518e | |||
| ff9d509137 | |||
| 278fd5bd59 | |||
| 8ddbc24dd4 | |||
| e93f429906 | |||
| 1dccb1bb64 | |||
| 6d9e67b9f2 | |||
| 4803d48ec7 | |||
| 7c6a12ea8e | |||
| a17d5d01a7 | |||
| cdd909f2dd | |||
| d0e9728c26 | |||
| c8ae428df8 | |||
| a9692f7db4 | |||
| d05a426d00 | |||
| a842b4c4d9 | |||
| 101abaeda1 | |||
| bb99167433 | |||
| e5b57e5ff9 | |||
| 2bd598f7f8 | |||
| b008c5f07f | |||
| a59aa2c30f | |||
| 93de0d4bf3 | |||
| 7a0ac2077c | |||
| 952f459e70 | |||
| 4702196c6e | |||
| f77e064f4e | |||
| c74a9a30bf | |||
| 1d43b36380 | |||
| 736f4edfbd | |||
| c7e2540f6f | |||
| f796296286 | |||
| eb206d2abb | |||
| bff62fb105 | |||
| 5fc83d7615 | |||
| 552a324e08 | |||
| b155d7d1ba | |||
| e4571bf668 | |||
| 53cc929a72 | |||
| 20f5a4cc91 | |||
| 2aabe559bf | |||
| dcf0902c5b | |||
| 0e8219b200 | |||
| 5f7fa0557f | |||
| 63fb4d57e2 | |||
| b5d6f44f4c | |||
| b21c8f8c3a | |||
| fe7c7660d3 | |||
| 56de33c821 | |||
| 1d442cd0e2 | |||
| c79d7957f6 | |||
| 1b9216b341 | |||
| 6d97e5fa5d | |||
| 162af35254 | |||
| abe03811f1 | |||
| e85a869733 | |||
| 0c32d5302c | |||
| f310196937 | |||
| c828dfd596 | |||
| d0b4e092b3 | |||
| 91d7d78621 | |||
| 2bc21f13d4 | |||
| 833edc9568 | |||
| 9d8efce26d | |||
| 6d778ca223 | |||
| 1e739e94e2 | |||
| 8c013aa2a9 | |||
| fb0a0b0dc2 | |||
| ab3d9ddb13 | |||
| a67b32b3ce | |||
| e15e690c5d | |||
| 55c4c25663 | |||
| 202ff53c41 | |||
| 992da7c7be | |||
| f47998a553 | |||
| f4d24420e7 | |||
| 308bdb3d46 | |||
| 20b99dce48 | |||
| e4f7c6add9 | |||
| 80110d1a48 | |||
| 0a0b45fb8e | |||
| 4ef29ae26f | |||
| ead4b89874 | |||
| e827fb2eb2 | |||
| 4f161fb891 | |||
| 8c30a013c7 | |||
| 9f3f877bfd | |||
| 84a75788af | |||
| f3615117d8 | |||
| 48a701ef87 | |||
| 41c72e0a8e | |||
| a83b875b66 | |||
| dcef08009d | |||
| eddba3c652 | |||
| f0c9a458bb | |||
| 62c9e271d8 | |||
| 871a25364d | |||
| e67abae3e0 | |||
| c50565dfda | |||
| 60c44da974 | |||
| ba6d9d0c23 | |||
| 8c55f38b07 | |||
| 568cf5e2ad | |||
| 5e843f7a4f | |||
| 090ada5807 | |||
| 0e17c57856 | |||
| 74464992e6 | |||
| a1d9c21337 | |||
| 248fc15716 | |||
| e38ddebfb6 | |||
| 57fc8b2f1a | |||
| b7fac8bcbc | |||
| 12f2eed5b3 | |||
| 3f39fd487f | |||
| 950bf14d95 | |||
| a279995982 | |||
| a2eb9734f1 | |||
| cb23991841 | |||
| 769d24d196 | |||
| af61f4f1db | |||
| f8f77075ec | |||
| 34bb5f9928 | |||
| ca3cced6ad | |||
| be905ac7be | |||
| 53f3ccc888 | |||
| c304670f47 | |||
| 6388894aa4 | |||
| c27b11bf25 | |||
| 11f395f65f | |||
| 63a0adaa6e | |||
| 0ddeb02d23 | |||
| c23acf9e9e | |||
| 54635bf0d3 | |||
| 6fdd9ed48b | |||
| a0399b7f5e | |||
| 387f6bcad4 | |||
| 0b43431543 | |||
| 34862f9ace | |||
| cd465ca35a | |||
| 93251e0029 | |||
| c2402ddb72 | |||
| d3dcb320f4 | |||
| fd9f734de1 | |||
| 9ea9bf4035 | |||
| f9b70d65d8 | |||
| 90621bb1e3 | |||
| 6ff339b552 | |||
| 9854f4eb2d | |||
| d46b046f2d | |||
| d02e8dcd4e | |||
| 07b1fe8e47 | |||
| 7c368ae029 | |||
| d6b5f92d6c | |||
| 2b70a49e09 | |||
| 8cfa20be1e | |||
| 246f6caf20 | |||
| 7750366654 | |||
| 0f963a93f1 | |||
| ea5b63af18 | |||
| 5e89675c9c | |||
| 5777c1ab27 | |||
| 8eda0aeab3 | |||
| 23c430fadc | |||
| aa423cfa5b | |||
| eb753a3f32 | |||
| 4a300a3cb2 | |||
| c4e16418e0 | |||
| 27e7a67a9a | |||
| ce9f140ddf | |||
| 85c3240b54 | |||
| 9c12e11375 | |||
| 630dbee817 | |||
| 18dc02c700 | |||
| 3d7e509f9a | |||
| ed27e6b8e4 | |||
| 631ce7645f | |||
| 181382b2b7 | |||
| ca15e69ae0 | |||
| ba64ba0bd0 | |||
| 1df4d32d69 | |||
| 5d380453a4 | |||
| ba629f1764 | |||
| f2edcaff85 | |||
| 6d358d4087 | |||
| 46dd50a744 | |||
| 3c2058f0e1 | |||
| c22c407ee5 | |||
| 1ed1dfc78a | |||
| 5797a1d8e5 | |||
| 6d5d40b8e3 | |||
| 90c6b18cbb | |||
| ecb7d5ef10 | |||
| e2b347c783 | |||
| 88a988d876 | |||
| fbeecc0479 | |||
| 413188c995 | |||
| 77818f9342 | |||
| c9ec161ccc | |||
| 20443f8a4d | |||
| 299ceac557 | |||
| 9365e5bfb9 | |||
| 3e75841a83 | |||
| 74b8a0f10f | |||
| c291729ed6 | |||
| a70245a3b1 | |||
| dde022d179 | |||
| 0d12c64c47 | |||
| 27d0a88b36 | |||
| ca55141276 | |||
| e20b9d054d | |||
| c1e3645d57 | |||
| 50db137dea | |||
| 5b109c2b79 | |||
| 25b7093302 | |||
| 38cbb87a62 | |||
| 0c0a978886 | |||
| fb5f368894 | |||
| 9454ffd1af | |||
| 16f35d9a34 | |||
| bb6a64790d | |||
| b9378118dd | |||
| b6485f91ae | |||
| e1e8ca9633 | |||
| 72f476a750 | |||
| f897809202 | |||
| 647d085c5f | |||
| 9d0f99c509 | |||
| fd25a23d91 | |||
| 7fdf165ff3 | |||
| b3e27da26d | |||
| 2479dc4096 | |||
| 7e7a5e692e | |||
| f628a6c3d6 | |||
| 5b0f95fed9 | |||
| 6aa98d5eac | |||
| 8e1fe9558e | |||
| 38c3e53ce7 | |||
| 9627766f7d | |||
| 57697142a2 | |||
| beb32755a3 | |||
| cb6e71e544 | |||
| 1487dcbadc | |||
| a4b27fdeab | |||
| 1137c11c59 | |||
| 14cd84dab7 | |||
| b5c5cd9586 | |||
| 85cc4cb8f7 | |||
| cf6732fb29 | |||
| 1207f5abad | |||
| 6e9394ec7a | |||
| 2c9e32b6c4 | |||
| fc470d0622 | |||
| a3270041e3 | |||
| 956068d0d6 | |||
| 3776e32364 | |||
| fb5a54dd17 | |||
| 916d564f82 | |||
| 364def188a | |||
| d1228a085b | |||
| 6c5a29fb48 | |||
| a83aecaa69 | |||
| 3d885ec262 | |||
| 6fdace07c8 | |||
| 8711658e75 | |||
| e25dc46863 | |||
| f53f54af7f | |||
| 763aa8865b | |||
| 2194cb65a2 | |||
| 60435d505f | |||
| af983c76b8 | |||
| ef161bbb31 | |||
| ac364e5ab7 | |||
| 2e2b1c6f18 | |||
| 1fa1496d7f | |||
| 8fb9365eaa | |||
| 92ab8331d0 | |||
| 603d373cee | |||
| aca2c3a9dd | |||
| f544dab3e0 | |||
| c489940f8b | |||
| dc7ddeaa9b | |||
| 9b5f42cda9 | |||
| 4022e4969d | |||
| ed62d06b5e | |||
| f11e4f6626 | |||
| 59eec5241a | |||
| d287486165 | |||
| f70270a0b3 | |||
| 36380fe5fd | |||
| dc7fca4f4c | |||
| 977759145e | |||
| 76c3660cb2 | |||
| fa10a67811 | |||
| 8d95fd0ca0 | |||
| 332e95701e | |||
| 124b24ab76 | |||
| 6ccd1e43bc | |||
| 5c09d04912 | |||
| 119325c3a2 | |||
| 462a559bd3 | |||
| 1bd58a0103 | |||
| 6c97d08027 | |||
| 0ebda9d224 | |||
| 808fc8dc0d | |||
| aefed73f5a | |||
| ea47750ea4 | |||
| 0f02bfd2c3 | |||
| 8e5a5baf52 | |||
| 3deb8eb488 | |||
| 1dd7f0371d | |||
| 7d032bb684 | |||
| ecc4a40eea | |||
| 83c6914a50 | |||
| 0f06d88e18 | |||
| ea5f7b65f3 | |||
| 9ce95da8f4 | |||
| abd1fd3efb | |||
| 90ab8dac27 | |||
| c96d556094 | |||
| 26f68a890e | |||
| cd5b7b17f6 | |||
| 14cfa69060 | |||
| d6d1b0eeef | |||
| 393d089229 | |||
| 706d9b1f6f | |||
| 0e9228ba7c | |||
| 5a17badfae | |||
| 437c6f8262 | |||
| 65d55d6660 | |||
| 7ba1aabc09 | |||
| 470fdd62bb | |||
| 6434d10e52 | |||
| 2ed4fc9fbf | |||
| b418895d9d | |||
| 8939927543 | |||
| 0bbe6a0a12 | |||
| 767784a79c | |||
| 64abfd4408 | |||
| 872e2f9753 | |||
| 09f7225eb7 | |||
| 95bb0ac6d4 | |||
| f97596689f | |||
| 204be84c0f | |||
| 93d8ba0b0f | |||
| b07c50e580 | |||
| fc0dc8aea0 | |||
| 284ed9dea1 | |||
| 1651a90dea | |||
| a888427777 | |||
| 6b53b78ee3 | |||
| c2faa605d3 | |||
| 8bf5a6e0bc | |||
| 80551124f1 | |||
| 652f8227b5 | |||
| 42f68f61c6 | |||
| fcb4104856 | |||
| a0139f4157 | |||
| 6c78060876 | |||
| b9b2f9f2c3 | |||
| 87d5cb78b2 | |||
| cdf421f0f1 | |||
| 2e58757bc9 | |||
| c689836208 | |||
| 4efc320f23 | |||
| 6c1a602bdc | |||
| 0ae994de56 | |||
| e7f4a5bd59 | |||
| 180973d49f | |||
| 705910d9e0 | |||
| b5dfc337ec | |||
| 8996b562bc | |||
| 1ae6186647 | |||
| 2848417cf5 | |||
| d3506acd94 | |||
| 9e9ea41bdd | |||
| 7b0aa7b770 | |||
| 3a25d108fe | |||
| d98e213b92 | |||
| 4d44562ada | |||
| b733b3c59f | |||
| 7b54988514 | |||
| ec4da47af6 | |||
| 633d59c13b | |||
| c06a92e0ae | |||
| 18bd9d62cb | |||
| 0bce6c6a46 | |||
| 0465442803 | |||
| eb667bc436 | |||
| c81628a66e | |||
| 50d3631bc4 | |||
| e971069595 | |||
| ac4c0ec1f6 | |||
| fe3d2e0af4 | |||
| a4b762e1b1 | |||
| daa0015fbd | |||
| 804248d6ad | |||
| 78c4c67a6c | |||
| c23be53bfd | |||
| d7e3e70430 | |||
| e95a859ee9 | |||
| 1a3704e700 | |||
| f49048c6e1 | |||
| 59226365c5 | |||
| 683ce431db | |||
| 8d4e796f42 | |||
| 3da1fbf6ca | |||
| 419e25df23 | |||
| 7fddf80c09 | |||
| fa85e61d6f | |||
| ebac0db0df | |||
| 0404f30c87 | |||
| 769fd7b524 | |||
| 4b5553abef | |||
| e730eb3a32 | |||
| 2933b6e732 | |||
| 94ae2f2658 | |||
| 3453451df9 | |||
| 80d4a2b242 | |||
| c9b1a062ce | |||
| 717ffe560f | |||
| 109e2fa82d | |||
| d03fc2fcf1 | |||
| d0111e7741 | |||
| 5e76d6d865 | |||
| 90e67b22b4 | |||
| 92ba6c9802 | |||
| 0286a51525 | |||
| b552e2cda8 | |||
| 0feb56cb3e | |||
| e1a0acdf4a | |||
| 6586f933ff | |||
| 4ec770da63 | |||
| 39b84a083d | |||
| 28857473b2 | |||
| 2bfc10dab4 | |||
| 1dce1157f4 | |||
| 08d53d52e7 | |||
| 2918d97fd0 | |||
| 701e170e19 | |||
| 71ee669fc5 | |||
| 661ac8c6f4 | |||
| 210f6b51df | |||
| 20611b6e4b | |||
| 689e4b8d23 | |||
| d0378ea528 | |||
| 5454ed3458 | |||
| 0070d46a20 | |||
| 540c10aaff | |||
| f5dfc3604a | |||
| b2c5304efe | |||
| fa512f16c6 | |||
| cbfb25b390 | |||
| b1e55201cf | |||
| 8beeb64127 | |||
| 2d2938c8ad | |||
| e9005d57da | |||
| 69f7c8a500 | |||
| 98fd24ffdc | |||
| 21c3e90b58 | |||
| 49281e0d15 | |||
| e597f81b45 | |||
| 9f6047aebd | |||
| 0745f964c1 | |||
| 18b1ad7b62 | |||
| 9448936e32 | |||
| f1dc436a07 | |||
| f163e24201 | |||
| 81f4f1c46f | |||
| a82e74381f | |||
| 7459896bac | |||
| dd2783b040 | |||
| 23ff2c3ab4 | |||
| ad51191a77 | |||
| 8ef5ab6fe9 | |||
| 2218f9781b | |||
| d08d727917 | |||
| 8cf030585f | |||
| c29515db38 | |||
| d9e27bfaf1 | |||
| 2e8830b9d3 | |||
| 4d3d6ea9a7 | |||
| e69f344ab2 |
@@ -1,2 +1 @@
|
|||||||
VITE_SENTRY_DSN=https://264a5e95c5d31fe080a2e92fb008294d@o4511430568378368.ingest.us.sentry.io/4511430571982849
|
|
||||||
VITE_APP_VERSION=lotus
|
VITE_APP_VERSION=lotus
|
||||||
|
|||||||
@@ -21,16 +21,38 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||||
|
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||||
|
# 3 times with backoff before failing the build.
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 20000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
npm config set fetch-timeout 600000
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
echo "npm ci attempt $attempt…"
|
||||||
|
npm ci && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
echo "npm ci failed after 3 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
|
||||||
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
|
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||||
|
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||||
|
# A failure blocks the deploy.
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
# ── Quality checks (informational — pre-existing issues exist) ───────
|
# ── Quality checks (informational — pre-existing issues exist) ───────
|
||||||
- name: TypeScript
|
- name: TypeScript
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
@@ -62,3 +84,35 @@ jobs:
|
|||||||
gzip_size=$(gzip -c "$f" | wc -c | awk '{printf "%.1f kB", $1/1024}')
|
gzip_size=$(gzip -c "$f" | wc -c | awk '{printf "%.1f kB", $1/1024}')
|
||||||
echo "| $name | $size | $gzip_size |" >> $GITHUB_STEP_SUMMARY
|
echo "| $name | $size | $gzip_size |" >> $GITHUB_STEP_SUMMARY
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── Desktop build trigger ──────────────────────────────────────────────
|
||||||
|
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
|
||||||
|
# `npm run build`) never bumps the cinny-desktop submodule and kicks off the
|
||||||
|
# slow Tauri release builds, which would only error out downstream. Only
|
||||||
|
# runs on a real push to lotus — not on pull_request CI runs.
|
||||||
|
trigger-desktop:
|
||||||
|
name: Trigger Desktop Build
|
||||||
|
needs: build
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Bump cinny submodule
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
CINNY_SHA="${{ github.sha }}"
|
||||||
|
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
|
||||||
|
cd desktop
|
||||||
|
git config user.email "ci@lotusguild.org"
|
||||||
|
git config user.name "Lotus CI"
|
||||||
|
git submodule update --init cinny
|
||||||
|
git -C cinny fetch origin
|
||||||
|
git -C cinny checkout "$CINNY_SHA"
|
||||||
|
git add cinny
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Submodule already at $CINNY_SHA, nothing to do"
|
||||||
|
else
|
||||||
|
git commit -m "chore: bump cinny submodule to ${CINNY_SHA:0:8}"
|
||||||
|
git push origin main
|
||||||
|
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||||
if: github.event.pull_request.head.repo.fork == false
|
if: github.event.pull_request.head.repo.fork == false
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||||
if: github.event.pull_request.head.repo.fork == false
|
if: github.event.pull_request.head.repo.fork == false
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -43,14 +43,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
ajbura/cinny
|
ajbura/cinny
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
|
|
||||||
- name: Build Docker image (no push)
|
- name: Build Docker image (no push)
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|||||||
@@ -70,27 +70,27 @@ jobs:
|
|||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ devAssets
|
|||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.ideapackage-lock.json
|
.ideapackage-lock.json
|
||||||
|
public/decorations/
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
save-exact=true
|
save-exact=true
|
||||||
|
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
# HANDOFF — Forking & Self-Building Element Call ("Lotus Call")
|
||||||
|
|
||||||
|
> **Audience:** a fresh Claude/engineer session with **no prior context** on this
|
||||||
|
> project. Read this top-to-bottom before touching anything. This document is the
|
||||||
|
> single source of truth for the Element Call (EC) fork initiative.
|
||||||
|
>
|
||||||
|
> **Status:** **PHASE 0–2 IMPLEMENTED (build-verified, not yet live-tested)**
|
||||||
|
> (2026-06-30). The fork exists, builds, is published, and cinny consumes it
|
||||||
|
> (Phase 0/1). **All 7 Phase-2 EC features are implemented on the fork's `lotus`
|
||||||
|
> branch**, each additive + flag-gated, build+typecheck-clean, per-feature
|
||||||
|
> reviewed (+ a holistic multi-agent review), and pushed. **None are live-tested
|
||||||
|
> yet** — every one needs the `LOTUS_TESTING.md` §D sweep, and the **cinny host
|
||||||
|
> side must be wired** (set flags / send actions / handle call_state) — see §12.
|
||||||
|
> See **§9** Phase 0/1 results, **§10** cutover, **§11** Phase-2 seams, **§12**
|
||||||
|
> Phase-2 status + cinny integration checklist. Created 2026-06 from `LotusGuild/cinny`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phase 0 Results (verified 2026-06-29)
|
||||||
|
|
||||||
|
**Decisions taken with the user:** scope = Phase 0 recon; consumption model =
|
||||||
|
**private npm package** (§5 option 1). Recommended registry = **Gitea's built-in
|
||||||
|
npm registry** (`code.lotusguild.org`) — zero new infra.
|
||||||
|
|
||||||
|
### 9.1 Version → tag → commit mapping (LOCKED)
|
||||||
|
|
||||||
|
| Source | Value |
|
||||||
|
| :--------------------------------------------------- | :----------------------------------------- |
|
||||||
|
| cinny `package.json` pin | `@element-hq/element-call-embedded@0.20.1` |
|
||||||
|
| Bundle self-report (`VITE_APP_VERSION`/`appVersion`) | `embedded-v0.20.1` |
|
||||||
|
| npm registry `gitHead` for 0.20.1 | `2d74c48151d9edc01c65a22a91478aac81bf24d0` |
|
||||||
|
| GitHub tag `v0.20.1` → commit | `2d74c48…` ✅ **same commit** |
|
||||||
|
|
||||||
|
→ **Fork from upstream tag `v0.20.1` (commit `2d74c48`).** The embedded package
|
||||||
|
version equals the element-call release tag; repo `package.json` version is
|
||||||
|
`0.0.0` and the real version is stamped at publish time from the tag.
|
||||||
|
|
||||||
|
### 9.2 The shipped npm dist is a CLEAN upstream build
|
||||||
|
|
||||||
|
No `lotus`/`denoise`/`rnnoise` strings anywhere in
|
||||||
|
`node_modules/@element-hq/element-call-embedded/dist`. **All Lotus customization
|
||||||
|
(denoise shim) is injected at cinny build time, not baked into the package** — so
|
||||||
|
swapping the source does not disturb cinny's denoise injection layer. The
|
||||||
|
ringtone/reaction assets (`baduntss`, `cat`, `clap`, `call_declined`, …) are
|
||||||
|
upstream EC's own, not ours.
|
||||||
|
|
||||||
|
### 9.3 Build toolchain & mechanism
|
||||||
|
|
||||||
|
- **Node `24`** (`.node-version`), **pnpm `10.33.0`** (`packageManager` field,
|
||||||
|
via corepack).
|
||||||
|
- Build: **`pnpm run build:embedded`** = `vite build --config
|
||||||
|
vite-embedded.config.ts` with `NODE_OPTIONS=--max-old-space-size=16384`.
|
||||||
|
- Output dir is **repo-root `dist/`**; CI stages it into **`embedded/web/dist`**
|
||||||
|
(the `embedded/web/` dir holds the publish template: `package.json`, README,
|
||||||
|
both LICENSE files).
|
||||||
|
- Publish workflow upstream = `.github/workflows/publish-embedded-packages.yaml`:
|
||||||
|
builds → `npm version <tag> --no-git-tag-version` → `npm publish --provenance
|
||||||
|
--access public` to npmjs as `@element-hq/element-call-embedded`. (Also
|
||||||
|
Android/Maven + iOS/SwiftPM — irrelevant; we are web-only.)
|
||||||
|
|
||||||
|
### 9.4 Build reproduction — PARITY CONFIRMED
|
||||||
|
|
||||||
|
Cloned `element-call@v0.20.1` to `/root/code/element-call` (shallow), built with
|
||||||
|
isolated Node 24 / pnpm 10.33.0 (system Node 20 / cinny untouched). Result vs the
|
||||||
|
shipped npm dist:
|
||||||
|
|
||||||
|
- **137 of 147 files byte-identical** (same Vite content-hash): all CSS, fonts,
|
||||||
|
wasm, audio, JSON locale files, and `IndexedDBWorker`.
|
||||||
|
- **Only 5 JS chunks differ** (`index`, `pako.esm`, `polyfill-force`,
|
||||||
|
`rust-crypto`, `spa`) — **cause isolated to the version define**: our local
|
||||||
|
build baked `appVersion:\`dev\``(because`VITE_APP_VERSION`was unset) vs the
|
||||||
|
npm build's`appVersion:\`embedded-v0.20.1\``. `index.html` is identical modulo
|
||||||
|
the hashed asset filenames. **Benign** — our CI sets the version from the git
|
||||||
|
tag, so a tagged CI build will match.
|
||||||
|
|
||||||
|
### 9.5 Fork CI (drafted)
|
||||||
|
|
||||||
|
`.gitea/workflows/ci.yml` is staged in the clone (models cinny's
|
||||||
|
`.gitea/workflows/ci.yml` + upstream's publish flow). Linux-only (`ubuntu-latest`)
|
||||||
|
— the Windows worker is for cinny-desktop/Tauri, not the EC web bundle. Build job
|
||||||
|
on PR/push to `lotus`; publish job on `v*` tag → `@lotusguild/element-call-embedded`
|
||||||
|
to the Gitea npm registry (needs `secrets.GITEA_NPM_TOKEN`).
|
||||||
|
|
||||||
|
### 9.6 Phase 1 — DONE (2026-06-29)
|
||||||
|
|
||||||
|
1. ✅ **Fork repo live:** `code.lotusguild.org/LotusGuild/element-call` (public,
|
||||||
|
AGPL), default branch `lotus`, full history (7018 commits) + tag `v0.20.1`.
|
||||||
|
Branch `lotus` = `v0.20.1` + 2-file diff (CI workflow + embedded package
|
||||||
|
rename).
|
||||||
|
2. ✅ **Package published:** `@lotusguild/element-call-embedded@0.20.1` on the
|
||||||
|
Gitea npm registry (published manually from the version-faithful build while
|
||||||
|
the admin token was available). **Publicly readable** (unauth `npm install`
|
||||||
|
works → devs/CI need no token to consume; only publishing needs one).
|
||||||
|
3. ✅ **cinny wired & built clean** (Node 24): `.npmrc` scope line +
|
||||||
|
`package.json` dep + `vite.config.js` `viteStaticCopy` src. `npm install`
|
||||||
|
swapped the package (resolved from Gitea), `npm run build` succeeded,
|
||||||
|
`dist/public/element-call/` populated, bundle reports `appVersion:
|
||||||
|
embedded-v0.20.1`, **denoise shim injected + all denoise assets copied**
|
||||||
|
(injection layer unchanged). **These cinny edits are staged in the working
|
||||||
|
tree, NOT committed/pushed** — pushing triggers CI → desktop → deploy, so it's
|
||||||
|
gated on the §D live test (see §10).
|
||||||
|
|
||||||
|
### 9.8 Reproducibility note (important)
|
||||||
|
|
||||||
|
A from-source rebuild is **NOT byte-identical** to upstream's npm tarball.
|
||||||
|
137/147 files match exactly (CSS, fonts, wasm, audio, worker); the 5 JS chunks
|
||||||
|
(`index`, `pako.esm`, `polyfill-force`, `rust-crypto`, `spa`) differ because the
|
||||||
|
rolldown/oxc **minifier mangles export names differently** across build
|
||||||
|
environments (and the version-define is one input). This is normal and benign —
|
||||||
|
the code is functionally equivalent. **Do not chase byte-parity; the §D live call
|
||||||
|
test is the real parity gate.**
|
||||||
|
|
||||||
|
### 9.9 Remaining follow-ups (not blocking the cutover)
|
||||||
|
|
||||||
|
- **CI publishing:** `.gitea/workflows/ci.yml` publishes on a `v*` tag but needs
|
||||||
|
(a) a Gitea Actions runner for `LotusGuild/element-call`, and (b) a **durable**
|
||||||
|
`GITEA_NPM_TOKEN` repo secret with package read/write (the admin token used for
|
||||||
|
the manual publish is being deleted, so it was deliberately NOT baked in). Until
|
||||||
|
then, publishing is manual (`npm version <tag>` in `embedded/web` →
|
||||||
|
`npm publish`).
|
||||||
|
- Decide rebase cadence vs upstream (0.20.2 / 0.20.3 already out — see §9.1).
|
||||||
|
|
||||||
|
### 9.7 Ready-to-apply artifacts (staged 2026-06-29)
|
||||||
|
|
||||||
|
**Fork side — already committed** on branch `lotus` in `/root/code/element-call`
|
||||||
|
(remote `lotus` = `code.lotusguild.org/LotusGuild/element-call.git`, push deferred
|
||||||
|
until the repo exists). Minimal 2-file diff vs tag `v0.20.1`:
|
||||||
|
`.gitea/workflows/ci.yml` (new) + `embedded/web/package.json` (rename to
|
||||||
|
`@lotusguild/element-call-embedded`). Push with:
|
||||||
|
`git push -u lotus lotus && git push lotus v0.20.1` (and tag `v0.20.1` on our side
|
||||||
|
to trigger the first publish, or push our own `v0.20.1` tag).
|
||||||
|
|
||||||
|
**cinny side — NOT yet applied** (applying before the package is published breaks
|
||||||
|
`npm ci`). Exactly 3 edits + a lockfile regen:
|
||||||
|
|
||||||
|
1. `.npmrc` — append the scoped-registry line:
|
||||||
|
```
|
||||||
|
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
|
||||||
|
```
|
||||||
|
(CI/auth: `//code.lotusguild.org/api/packages/LotusGuild/npm/:_authToken=${GITEA_NPM_TOKEN}`
|
||||||
|
— inject via env in CI, do not commit a plaintext token.)
|
||||||
|
2. `package.json:104` —
|
||||||
|
`"@element-hq/element-call-embedded": "0.20.1"` →
|
||||||
|
`"@lotusguild/element-call-embedded": "0.20.1"`.
|
||||||
|
3. `vite.config.js:25` — `viteStaticCopy` src:
|
||||||
|
`node_modules/@element-hq/element-call-embedded/dist` →
|
||||||
|
`node_modules/@lotusguild/element-call-embedded/dist`.
|
||||||
|
**`stripBase: 4` stays unchanged** — `node_modules/@lotusguild/element-call-embedded/dist`
|
||||||
|
is still exactly 4 leading segments. (Update the comment's path reference too.)
|
||||||
|
4. `package-lock.json` — regenerated by `npm install`, not hand-edited (drops the
|
||||||
|
`registry.npmjs.org/@element-hq/...` resolved URL for the Gitea one).
|
||||||
|
|
||||||
|
The denoise injection (`lotusDenoise()` in `vite.config.js`) is **unchanged** — it
|
||||||
|
keys off `dist/public/element-call/index.html`, which our fork's bundle still
|
||||||
|
produces identically (verified: `index.html` byte-identical modulo asset hashes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. TL;DR / The Goal
|
||||||
|
|
||||||
|
We embed **Element Call** (the Matrix group-VoIP/video app) inside Lotus Chat to
|
||||||
|
power voice/video channels. Today we consume Element's **pre-compiled npm
|
||||||
|
bundle** and can only steer it from the outside (a limited widget API + fragile
|
||||||
|
same-origin DOM hacks). Several in-call problems are **unfixable from outside**
|
||||||
|
because they live in EC's compiled JS.
|
||||||
|
|
||||||
|
**We want true ownership: fork `element-hq/element-call`, build it from source
|
||||||
|
ourselves, host our build, and replace the npm bundle with our fork.** Then
|
||||||
|
every in-call behavior becomes editable code.
|
||||||
|
|
||||||
|
**This requires standing up a brand-new repo and build pipeline for our EC fork.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why fork? (What we cannot fix today)
|
||||||
|
|
||||||
|
These came out of live testing and are documented in `LOTUS_BUGS.md` →
|
||||||
|
"Known Element Call iframe limitations":
|
||||||
|
|
||||||
|
| Issue | What's wrong | Why outside-fixes fail |
|
||||||
|
| :----------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **A6** — avatar decorations in-call | Our profile-decoration overlays don't appear on in-call video tiles | The video grid is rendered by EC's React app inside the iframe. We can only inject overlay DOM (fragile) — we can't make it a first-class part of the tile. |
|
||||||
|
| **A5** — focus camera / fullscreen during screenshare | Can't reliably spotlight a participant's camera while someone screenshares | EC's **layout logic** (screenshare priority, spotlight) is compiled JS we don't control. We currently DOM-click tiles as a hack. |
|
||||||
|
| **A7** — mic dead after EC's "Reconnect" | After EC's own mid-call reconnect, the local mic isn't re-published | EC's reconnect/track-republish path is internal. (Partly entangled with our denoise shim — see §6.) |
|
||||||
|
| Native theming | EC's UI doesn't match Lotus design; we inject CSS hacks | Real theming needs source-level component/token changes. |
|
||||||
|
| Decorations, custom controls, custom layouts, branding | all blocked | all require source access |
|
||||||
|
|
||||||
|
**Bottom line:** the iframe is **same-origin** (we self-host it), so we can read
|
||||||
|
and even write its DOM — but we **do not own its source**, so we can't change its
|
||||||
|
**behavior/logic**, only poke at its rendered output. Forking removes that wall.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. How EC is integrated TODAY (the current architecture)
|
||||||
|
|
||||||
|
Understand this fully before changing it — the fork must slot into the same
|
||||||
|
integration seams.
|
||||||
|
|
||||||
|
### 2.1 Where the EC bundle comes from
|
||||||
|
|
||||||
|
- npm package: **`@element-hq/element-call-embedded`**, pinned to **`0.20.1`** in
|
||||||
|
`cinny/package.json` (line ~104).
|
||||||
|
- It ships a **pre-built `dist/`**. At cinny build time,
|
||||||
|
`vite-plugin-static-copy` copies that `dist/` flat into
|
||||||
|
**`public/element-call/`** (see `cinny/vite.config.js`, the `copyFiles`
|
||||||
|
target with `rename: { stripBase: 4 }` — note the stripBase gotcha documented
|
||||||
|
there; getting this wrong 404s the widget).
|
||||||
|
- It is **NOT committed** to git (`git ls-files public/element-call` → 0). It's a
|
||||||
|
build artifact materialized from `node_modules`.
|
||||||
|
|
||||||
|
### 2.2 How EC is loaded & controlled
|
||||||
|
|
||||||
|
- The widget iframe `src` is **same-origin**:
|
||||||
|
`${BASE_URL}/public/element-call/index.html?<params>` (see
|
||||||
|
`cinny/src/app/plugins/call/CallEmbed.ts`, `getWidget()` /
|
||||||
|
`getIframe()`). Sandbox: `allow-forms allow-scripts allow-same-origin
|
||||||
|
allow-popups allow-modals allow-downloads`; `allow="microphone; camera;
|
||||||
|
display-capture; autoplay; clipboard-write;"`.
|
||||||
|
- **Control surface #1 — the official widget API** (`matrix-widget-api`):
|
||||||
|
`ClientWidgetApi` + a custom `CallWidgetDriver`. This is the robust,
|
||||||
|
version-stable channel (theme change, hangup, capabilities, timeline events).
|
||||||
|
Files: `plugins/call/CallEmbed.ts`, `plugins/call/CallWidgetDriver.ts`,
|
||||||
|
`plugins/call/utils.ts` (capabilities), `plugins/call/CallControl.ts`.
|
||||||
|
- **Control surface #2 — same-origin DOM poking** (fragile, version-coupled):
|
||||||
|
reading `iframe.contentDocument` to detect speakers/mute state and
|
||||||
|
`.click()`-ing tiles to focus a camera. Files:
|
||||||
|
`hooks/useCallSpeakers.ts` (reads `[data-muted]`, `[data-video-fit]`),
|
||||||
|
`plugins/call/CallControl.ts` (`focusCameraParticipant` — tile selectors).
|
||||||
|
**These selectors break on every EC version bump.** A fork lets us replace
|
||||||
|
these hacks with real APIs/props.
|
||||||
|
- **Control surface #3 — URL params + build-time injection** for our denoise
|
||||||
|
shim (see §6).
|
||||||
|
|
||||||
|
### 2.3 Full file inventory (everything that touches EC in cinny)
|
||||||
|
|
||||||
|
Plugin / core:
|
||||||
|
|
||||||
|
- `src/app/plugins/call/CallEmbed.ts` — iframe creation, widget API wiring, theme sync, hangup, load watchdog/self-heal, denoise URL params.
|
||||||
|
- `src/app/plugins/call/CallControl.ts` — control state + **DOM-poking** (`focusCameraParticipant`, spotlight).
|
||||||
|
- `src/app/plugins/call/CallControl.tsx` _(call-status variant)_ and `features/call-status/CallControl.tsx`.
|
||||||
|
- `src/app/plugins/call/CallWidgetDriver.ts` — widget driver (capabilities, event relay).
|
||||||
|
- `src/app/plugins/call/utils.ts` — widget capabilities set.
|
||||||
|
- `src/app/plugins/call/hooks.ts`, `index.ts` — plugin exports/hooks.
|
||||||
|
- `src/app/state/callEmbed.ts` — jotai atoms for the active embed.
|
||||||
|
|
||||||
|
React / UI:
|
||||||
|
|
||||||
|
- `src/app/components/CallEmbedProvider.tsx` — the big one: incoming-call ring/banner, RTCNotification + **RTCDecline** listeners, PiP, mute badges, fullscreen, ringtones.
|
||||||
|
- `src/app/features/call/CallView.tsx` — prescreen lobby vs joined (the iframe placement target), load-error recovery UI.
|
||||||
|
- `src/app/features/call/CallControls.tsx` — in-call control bar (mic/cam/deafen/screenshare/fullscreen/more/PiP).
|
||||||
|
- `src/app/features/call/CallMemberCard.tsx` — **lobby** participant roster (this is where `AvatarDecoration` works today; in-call grid is EC's).
|
||||||
|
- `src/app/features/call/PrescreenControls.tsx` — join controls.
|
||||||
|
- `src/app/features/call-status/*` — `CallStatus.tsx`, `MemberGlance.tsx` (the "Focus camera" menu lives here), `LiveChip.tsx`.
|
||||||
|
- `src/app/features/room-nav/RoomNavItem.tsx`, `features/room/Room.tsx`, `features/room/RoomViewHeader.tsx`, `pages/client/space/Space.tsx`, `pages/CallStatusRenderer.tsx`, `pages/Router.tsx` — call entry points / status surfacing.
|
||||||
|
|
||||||
|
Hooks:
|
||||||
|
|
||||||
|
- `src/app/hooks/useCallEmbed.ts`, `useCall.ts`, `useCallSpeakers.ts` (DOM-poking), `useCallJoinLeaveSounds.ts`, `useAfkAutoMute.ts`.
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
- `cinny/vite.config.js` — `copyFiles` (EC dist copy) + `lotusDenoise()` plugin (denoise asset copy + index.html shim injection, in `closeBundle`).
|
||||||
|
|
||||||
|
Utils:
|
||||||
|
|
||||||
|
- `src/app/utils/ringtones.ts`, `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Hosting / infra context (the OTHER repo)
|
||||||
|
|
||||||
|
There are **two repos**:
|
||||||
|
|
||||||
|
1. **`LotusGuild/cinny`** (`/root/code/cinny`) — this Lotus Chat fork. Consumes EC.
|
||||||
|
2. **`LotusGuild/matrix`** (`/root/code/matrix`) — the **infra/homeserver** repo.
|
||||||
|
Subdirs: `livekit/` (the SFU EC talks to), `deploy/`, `draupnir/`,
|
||||||
|
`hookshot/`, `landing/`, `matrixbot/`, `systemd/`. Gitea remote
|
||||||
|
`code.lotusguild.org/LotusGuild/matrix`, branch `main`.
|
||||||
|
|
||||||
|
EC needs a **LiveKit SFU** + the **livekit-jwt-service**; those live in
|
||||||
|
`matrix/livekit/`. A self-hosted EC build must be configured to point at our
|
||||||
|
homeserver (`matrix.lotusguild.org` / synapse) and our LiveKit. EC's runtime
|
||||||
|
`config.json` (homeserver, livekit URL, feature flags) is part of what we'll own
|
||||||
|
once we build it ourselves.
|
||||||
|
|
||||||
|
Deployment today: `chat.lotusguild.org` (the cinny web build, which embeds EC at
|
||||||
|
`/public/element-call/`). cinny-desktop (`LotusGuild/cinny-desktop`, a Tauri
|
||||||
|
wrapper, bumped by cinny CI) embeds the same.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The plan (proposed — confirm with the user before executing)
|
||||||
|
|
||||||
|
### Decision: **YES, create a new repo.** `LotusGuild/element-call`
|
||||||
|
|
||||||
|
Rationale: EC is a large standalone app (React + LiveKit client SDK + matrixRTC +
|
||||||
|
its own Vite build + heavy deps). Keep it out of cinny so cinny's build stays
|
||||||
|
clean — cinny keeps consuming a **built EC `dist/`**, exactly as today, just
|
||||||
|
sourced from **our fork** instead of npm.
|
||||||
|
|
||||||
|
### Phase 0 — Recon (no code)
|
||||||
|
|
||||||
|
- Fork `github.com/element-hq/element-call` → `LotusGuild/element-call` on Gitea.
|
||||||
|
- Pin to the upstream tag matching **0.20.1** (`element-call-embedded` 0.20.1's
|
||||||
|
corresponding `element-call` release) so behavior matches what's shipping now.
|
||||||
|
Verify the embedded-package version ↔ element-call repo tag mapping.
|
||||||
|
- Read EC's own build docs: it builds the "embedded" widget bundle (the thing
|
||||||
|
currently published as `@element-hq/element-call-embedded`). Reproduce that
|
||||||
|
build locally and confirm the output matches `public/element-call/` today.
|
||||||
|
- **License:** element-call is **AGPL-3.0**, same as Lotus Chat — compatible.
|
||||||
|
Our fork must remain AGPL and publish source.
|
||||||
|
|
||||||
|
### Phase 1 — Reproduce current behavior from our fork (parity, no features)
|
||||||
|
|
||||||
|
- Build our fork's embedded bundle; wire cinny to consume it instead of the npm
|
||||||
|
package (see §5 for the consumption options). Smoke-test: a call works exactly
|
||||||
|
as today (web + desktop), denoise shim still injects, widget API + theme still
|
||||||
|
work. **No behavior change yet** — this de-risks the swap.
|
||||||
|
|
||||||
|
### Phase 2 — Replace the outside hacks with source-level features
|
||||||
|
|
||||||
|
Tackle the §1 issues in EC's source:
|
||||||
|
|
||||||
|
- **A6:** render avatar decorations as part of the video-tile component
|
||||||
|
(read decoration data we pass in via widget data / URL param / a small bridge).
|
||||||
|
- **A5:** fix focus/spotlight + screenshare-coexistence in EC's layout code;
|
||||||
|
expose a clean widget action so cinny can trigger it (kill the DOM `.click()`).
|
||||||
|
- **A7:** fix mic re-publish on reconnect; reconcile with our denoise shim (§6) —
|
||||||
|
ideally move denoise INTO the fork as a real audio-processing step instead of a
|
||||||
|
`getUserMedia` monkeypatch.
|
||||||
|
- Native Lotus theming/branding at the source (kill the injected-CSS hacks).
|
||||||
|
- Then retire the DOM-poking in `useCallSpeakers.ts` / `CallControl.ts` in favor
|
||||||
|
of real widget messages.
|
||||||
|
|
||||||
|
### Phase 3 — Maintenance posture
|
||||||
|
|
||||||
|
- Decide rebase cadence vs. upstream element-call releases. Keep customizations
|
||||||
|
isolated (feature flags / minimal-diff patches) to ease rebasing.
|
||||||
|
- CI in the new repo builds + publishes the embedded dist as a versioned
|
||||||
|
artifact; cinny CI consumes a pinned version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. How cinny should consume the fork (pick one — decide with user)
|
||||||
|
|
||||||
|
1. **Private npm package** (mirror the current model): our fork's CI publishes
|
||||||
|
`@lotusguild/element-call-embedded` to a registry; cinny depends on it and
|
||||||
|
`viteStaticCopy` keeps working almost unchanged. _Cleanest swap; needs a
|
||||||
|
registry._
|
||||||
|
2. **Git submodule + build in cinny CI:** add the fork as a submodule, build it
|
||||||
|
during cinny's build, copy its `dist/` to `public/element-call/`. _No
|
||||||
|
registry; heavier cinny CI._
|
||||||
|
3. **CI artifact copy:** fork CI uploads a `dist` tarball; cinny CI downloads a
|
||||||
|
pinned version at build. _Decoupled; needs artifact plumbing._
|
||||||
|
|
||||||
|
**Recommendation: Option 1** — it changes the least in cinny (just swap the
|
||||||
|
package name in `package.json` + the `viteStaticCopy` src path) and preserves the
|
||||||
|
clean cinny/EC separation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The denoise shim — critical interaction (don't break this)
|
||||||
|
|
||||||
|
Lotus ships ML noise suppression by **injecting a same-origin pre-init shim into
|
||||||
|
EC's `index.html` at build time** (cinny `vite.config.js` → `lotusDenoise()`,
|
||||||
|
`closeBundle`). The shim monkeypatches `getUserMedia` **before EC captures the
|
||||||
|
mic** and routes audio through RNNoise/Speex/DTLN AudioWorklets, then EC/LiveKit
|
||||||
|
publishes the processed track. It's activated via URL params
|
||||||
|
(`lotusDenoise=ml&lotusModel=…&lotusGate=…`) set in `CallEmbed.ts`.
|
||||||
|
|
||||||
|
- Assets copied to `public/element-call/denoise/` at build (sapphi RNNoise/Speex/
|
||||||
|
gate worklets + `@workadventure/noise-suppression` DTLN tree).
|
||||||
|
- Related: `utils/denoisePipeline.ts`, `utils/lotusDenoiseUtils.ts`,
|
||||||
|
`settings/general/DenoiseTester.tsx`, `VoiceMessageRecorder.tsx`.
|
||||||
|
- **Known issues:** denoise quality is still poor (tracked separately); and the
|
||||||
|
mic-after-reconnect bug (A7) is suspected to involve the shim's getUserMedia
|
||||||
|
patch handing back a stale processed stream when EC re-acquires the mic.
|
||||||
|
|
||||||
|
**Once we own the fork, the right move is to make denoise a first-class
|
||||||
|
audio-processing stage inside EC** (not an index.html monkeypatch) — more robust,
|
||||||
|
survives reconnects, and removes the build-time injection hack. Until then, the
|
||||||
|
fork's `index.html` must remain injectable the same way, or the shim must be
|
||||||
|
re-homed into the fork.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Doc-accuracy notes / corrections for the new session
|
||||||
|
|
||||||
|
- `LOTUS_TODO.md` (~line 533) calls EC a **"cross-origin iframe"** — **outdated.**
|
||||||
|
EC is **same-origin** today (self-hosted under our domain;
|
||||||
|
`iframe.sandbox` includes `allow-same-origin`; we read `contentDocument`), and
|
||||||
|
**as of 2026-06-29 we own the fork's source** (`@lotusguild/element-call-embedded`).
|
||||||
|
The _practical_ point it made still holds _until we ship the audio-inject API_:
|
||||||
|
**LiveKit's `LocalAudioTrack` lives in EC's module scope**, not on `window`, so
|
||||||
|
cinny can't reach it even same-origin — which is why the in-call soundboard had
|
||||||
|
to be local-playback-only. **The fork removes this wall:** EC can expose a real
|
||||||
|
`io.lotus.inject_audio` widget action (Phase 2) that mixes into the published
|
||||||
|
track from inside its own module scope.
|
||||||
|
- `LOTUS_FEATURES.md` documents the EC upgrade history (0.16.3 → 0.19.4 →
|
||||||
|
0.20.1), the dark-mode CSS injection, and AFK auto-mute — all relevant prior
|
||||||
|
art for what the fork must preserve.
|
||||||
|
- `LOTUS_TESTING.md` §D is the **EC regression sweep** to re-run after the fork
|
||||||
|
swap (Phase 1 parity check).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. First actions for the new session
|
||||||
|
|
||||||
|
1. Read this file, then skim §2.3's files in `cinny` to internalize the seams.
|
||||||
|
2. Confirm with the user: new repo name, consumption model (§5), rebase cadence.
|
||||||
|
3. Phase 0: fork element-call, map 0.20.1 ↔ element-call tag, reproduce the
|
||||||
|
embedded build locally, diff against `public/element-call/`.
|
||||||
|
4. Phase 1: wire cinny to the fork, run `LOTUS_TESTING.md` §D parity sweep.
|
||||||
|
5. Only then start Phase 2 features (A5/A6/A7, theming, denoise-in-source).
|
||||||
|
|
||||||
|
**Cross-references:** `LOTUS_BUGS.md` (EC limitations + verify queue),
|
||||||
|
`LOTUS_TODO.md` (denoise/soundboard constraints), `LOTUS_FEATURES.md` (EC history),
|
||||||
|
`LOTUS_TESTING.md` §D (regression sweep). Infra: `/root/code/matrix` (`livekit/`,
|
||||||
|
`deploy/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Live cutover — the remaining steps (Phase 1 finish)
|
||||||
|
|
||||||
|
The fork is published and cinny builds against it locally (§9.6). What's left to
|
||||||
|
go live:
|
||||||
|
|
||||||
|
1. **Run `LOTUS_TESTING.md` §D** against a local cinny build (`npm run build` is
|
||||||
|
already proven; serve `dist/` or `npm run dev`). Verify a real call: join,
|
||||||
|
mic/cam, screenshare, theme sync, denoise on, widget hangup — web first.
|
||||||
|
2. **Commit the cinny edits** (currently staged, uncommitted in the working tree):
|
||||||
|
`.npmrc`, `package.json`, `package-lock.json`, `vite.config.js`. Suggested
|
||||||
|
message: `chore(call): consume self-built @lotusguild/element-call-embedded`.
|
||||||
|
3. **Push to `lotus`** → cinny CI builds, then `trigger-desktop` bumps
|
||||||
|
cinny-desktop → Tauri release. Re-run §D on **cinny-desktop** (the path where
|
||||||
|
the old `stripBase` bug bit — verify the widget loads, not a 404).
|
||||||
|
4. Only then start **Phase 2** (A5/A6/A7, theming, denoise-in-source).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 2 — implementation seams (mapped 2026-06-29)
|
||||||
|
|
||||||
|
The exact integration points for each Phase 2 item, found by reading the EC fork
|
||||||
|
|
||||||
|
- cinny source. **All of these are media-path / in-call features that cannot be
|
||||||
|
functionally verified without a live Matrix + LiveKit call** — implement each as
|
||||||
|
a minimal, **feature-flagged, additive** diff (no behavior change unless cinny
|
||||||
|
opts in), build-verify the fork (`pnpm build:embedded`, ~15s) AND cinny
|
||||||
|
(`npm run build`), then gate shipping on `LOTUS_TESTING.md` §D.
|
||||||
|
|
||||||
|
**Shared widget channel (the backbone for #2/#3/#4/#7):**
|
||||||
|
|
||||||
|
- EC→cinny: `widget.api.transport.send("io.lotus.<x>", data)` (see
|
||||||
|
`element-call/src/widget.ts`).
|
||||||
|
- cinny→EC actions: add the action name to the `lazyActions` allow-list in
|
||||||
|
`widget.ts` (the array at ~L101) and handle it in EC; cinny sends via
|
||||||
|
`this.call.transport.send(...)`.
|
||||||
|
- cinny receives EC→cinny actions via the existing `listenAction(type, cb)`
|
||||||
|
helper in `plugins/call/CallEmbed.ts:626` (auto-replies `{}` so the transport
|
||||||
|
doesn't time out — same pattern as `io.element.device_mute`).
|
||||||
|
|
||||||
|
**#2 mute/speaker events** — Source: subscribe to `vm.userMedia$`
|
||||||
|
(`CallViewModel`), per member `speaking$` + `audioEnabled$`
|
||||||
|
(`state/media/UserMediaViewModel.ts:47-48`); aggregate and
|
||||||
|
`transport.send("io.lotus.call_state", {participants:[{id,speaking,audioEnabled}]})`.
|
||||||
|
Mount in `room/InCallView.tsx` via `useEffect` guarded by `widget !== null`.
|
||||||
|
cinny: `listenAction("io.lotus.call_state")` in `CallEmbed.ts`, feed
|
||||||
|
`hooks/useCallSpeakers.ts` → delete its `contentDocument` `[data-muted]` /
|
||||||
|
`[data-video-fit]` scrape. _Additive, low risk._
|
||||||
|
|
||||||
|
**#4 spotlight/focus** — EC: add `io.lotus.focus_participant` to the `lazyActions`
|
||||||
|
list (`widget.ts`), drive `vm`'s spotlight (`spotlightSpeaker$` /
|
||||||
|
`spotlight$` in `CallViewModel.ts:898/1001`) to pin a given identity, coexisting
|
||||||
|
with `hasRemoteScreenShares$` (L1008). cinny: replace
|
||||||
|
`CallControl.ts` `focusCameraParticipant` `.click()` walk with
|
||||||
|
`transport.send("io.lotus.focus_participant", {userId})`. _Additive, low risk._
|
||||||
|
|
||||||
|
**#3 audio-inject** — EC: add `io.lotus.inject_audio` action; mix an
|
||||||
|
`AudioBufferSourceNode` into the published mic track. The local publish path is
|
||||||
|
`state/CallViewModel/localMember/Publisher.ts` + `LocalMember.ts` (LiveKit
|
||||||
|
`localParticipant`); create a `MediaStreamAudioDestinationNode`, mix mic + clip,
|
||||||
|
`replaceTrack`. cinny soundboard calls the action instead of local-only playback.
|
||||||
|
_Medium; touches publish path → live-test carefully._
|
||||||
|
|
||||||
|
**#1 denoise-in-source** — replace the cinny `lotusDenoise()` `getUserMedia`
|
||||||
|
monkeypatch with a real processing stage in EC's mic capture
|
||||||
|
(`Publisher.ts`/`LocalMember.ts`; note EC has a `TrackProcessorContext` +
|
||||||
|
`BlurBackgroundTransformer` precedent in `livekit/`). EC re-runs it on every
|
||||||
|
(re)publish → fixes A7. Remove `vite.config.js` `lotusDenoise()` + URL params in
|
||||||
|
`CallEmbed.ts`; move `denoise/` assets into the fork. _Highest value, highest
|
||||||
|
risk — most live testing._
|
||||||
|
|
||||||
|
**#5 theming** — add a Lotus/TDS theme in EC's theme system (`src/useTheme.ts` +
|
||||||
|
EC theme tokens / CSS); driven by the existing `setTheme()` channel cinny already
|
||||||
|
calls (`CallEmbed.ts:277`). Bake transparent background. Delete cinny's
|
||||||
|
`applyStyles()` injection + `background:none !important`. _Medium._
|
||||||
|
|
||||||
|
**#6 in-call decorations** — render the decoration APNG in EC's tile component
|
||||||
|
(`tile/GridTile.tsx`); pass slugs via widget member data. cinny already has the
|
||||||
|
decoration data + `AvatarDecoration` (lobby `CallMemberCard.tsx`). _Medium-Large._
|
||||||
|
|
||||||
|
**#7 quality controls** — set audio `maxBitrate` via
|
||||||
|
`RTCRtpSender.setParameters` and screenshare `getDisplayMedia` constraints in
|
||||||
|
EC's publish path (`Publisher.ts`); configurable via `config.json` / a widget
|
||||||
|
message. Keep the server `voice-limit-guard` as enforcement. _Medium._
|
||||||
|
|
||||||
|
**Rollback:** revert the 4 cinny files (restores `@element-hq/...@0.20.1` from
|
||||||
|
npmjs). The fork repo/package can stay; nothing else depends on it until pushed.
|
||||||
|
|
||||||
|
### Local repro/build environment (this session, 2026-06-29)
|
||||||
|
|
||||||
|
- Upstream cloned + our `lotus` branch at `/root/code/element-call` (remote
|
||||||
|
`lotus` → Gitea; origin → github upstream, now un-shallowed/full history).
|
||||||
|
- Isolated **Node 24.18.0** lives in the session scratchpad (system Node is 20);
|
||||||
|
cinny's `.node-version` is `24.13.1`, so use Node 24 to build cinny too.
|
||||||
|
- Build the embedded bundle: in `/root/code/element-call`, with Node 24 + pnpm
|
||||||
|
10.33.0 on PATH, `VITE_APP_VERSION=embedded-v0.20.1 pnpm run build:embedded`
|
||||||
|
→ output in `dist/`; stage to `embedded/web/dist` before publishing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Phase 2 — IMPLEMENTED on the fork (2026-06-30)
|
||||||
|
|
||||||
|
All 7 EC features are on the `lotus` branch of `LotusGuild/element-call`, each
|
||||||
|
**additive + feature-flagged** (a vanilla call with no `lotus*` params / no Lotus
|
||||||
|
actions behaves exactly like upstream), build + `tsc` clean, per-feature reviewed
|
||||||
|
(fixes applied) and holistically reviewed. **Not yet live-tested** — all need the
|
||||||
|
`LOTUS_TESTING.md` §D sweep.
|
||||||
|
|
||||||
|
Fork modules live under `element-call/src/lotus/*`; mounts are `useEffect`s in
|
||||||
|
`src/room/InCallView.tsx`. Custom widget actions are in `src/lotus/lotusActions.ts`
|
||||||
|
(toWidget ones allow-listed in `src/widget.ts`).
|
||||||
|
|
||||||
|
| # | Feature | Enable via | EC module |
|
||||||
|
| :-- | :------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------ | ---------------------------------------------------- |
|
||||||
|
| 2 | Speaker/mute/camera state → host | URL `lotusCallState=1` | `lotusCallState.ts` (sends `io.lotus.call_state`) |
|
||||||
|
| 4 | Focus/spotlight a participant (works during screenshare) | action `io.lotus.focus_participant {userId | null}` | `lotusFocus.ts` + `CallViewModel` spotlight override |
|
||||||
|
| 3 | Soundboard audio-inject (heard by peers) | URL `lotusAudioInject=1` + action `io.lotus.inject_audio {url,volume?}` | `lotusAudioInject.ts` |
|
||||||
|
| 7 | Audio/screenshare quality caps | action `io.lotus.set_quality {audioMaxBitrate?,screenshareMaxBitrate?,screenshareMaxFramerate?}` | `lotusQuality.ts` |
|
||||||
|
| 5 | Transparent bg + Lotus theme | URL `lotusTransparent=1` / `lotusTheme=1` | `useTheme.ts` + `index.css` |
|
||||||
|
| 6 | In-call avatar decorations | action `io.lotus.decorations {decorations:{userId:url}}` | `lotusDecorations.ts` + `MediaView.tsx` |
|
||||||
|
| 1 | ML denoise in-source (fixes A7) | URL **`lotusDenoiseSource=1`** (+`lotusModel`,`lotusGate`,`lotusGateThreshold`,`lotusDenoiseBase`) — deliberately NOT the existing `lotusDenoise=ml` (that drives the host shim; reusing it would double-process) | `lotusDenoise.ts` + `lotusDenoiseProcessor.ts` |
|
||||||
|
|
||||||
|
**Security hardening applied** (holistic audit): `lotusDenoiseBase` forced
|
||||||
|
same-origin before `audioWorklet.addModule` (was an arbitrary-code-load vector
|
||||||
|
via a crafted link); audio-inject gated behind `lotusAudioInject=1`; decoration
|
||||||
|
roster capped. Only `https`/`blob` URLs accepted for inject/decoration assets.
|
||||||
|
|
||||||
|
### 12.1 cinny host integration checklist (REQUIRED to light these up)
|
||||||
|
|
||||||
|
> ✅ **STATUS (2026-06): COMPLETE.** All items below are shipped. call_state,
|
||||||
|
> focus_participant, decorations, and transparent background are active; the
|
||||||
|
> in-source denoise cutover is done (flag `lotusDenoiseSource=1`, **all four**
|
||||||
|
> models in-source); and the two formerly-dormant capabilities now have cinny
|
||||||
|
> UI — **soundboard** (`io.lotus.inject_audio`, P5-15) and **quality controls +
|
||||||
|
> room permissions** (`io.lotus.set_quality` + `io.lotus.room_quality`, P5-31,
|
||||||
|
> with server-side enforcement in `LotusGuild/matrix`). See `LOTUS_FEATURES.md`
|
||||||
|
> → "Element Call — Self-Built Fork". The checklist is kept below as the record
|
||||||
|
> of what was wired. (One open denoise item tracked separately: the "Series
|
||||||
|
> Suppression" native-NS toggle is not wired to the real call path.)
|
||||||
|
|
||||||
|
The EC side is additive and dormant until cinny opts in. Host work (in
|
||||||
|
`src/app/plugins/call/CallEmbed.ts` unless noted) — **done**:
|
||||||
|
|
||||||
|
> ⚠️ **CRITICAL TIMING (protocol audit F1):** only send `io.lotus.*` **toWidget**
|
||||||
|
> actions (#3 focus, #6 decorations, #7 quality, audio-inject) **after** the call
|
||||||
|
> is joined (`CallEmbed.onCallJoined` / `this.joined`). Those actions are
|
||||||
|
> allow-listed at EC app-init (so `preventDefault` suppresses the auto-error)
|
||||||
|
> but their handlers only mount with `InCallView` (post-join). Sending earlier
|
||||||
|
> leaves the host's `transport.send` pending until the **10s timeout**. Queue and
|
||||||
|
> flush on join, or no-op before join.
|
||||||
|
>
|
||||||
|
> Also: **F3 (RESOLVED)** — all four models (`rnnoise`/`speex`/`dtln`/
|
||||||
|
> `deepfilternet`) are now implemented in-source in `lotusDenoiseProcessor.ts`;
|
||||||
|
> the picker offers all four. **F4** — cinny no longer forwards a native-NS flag
|
||||||
|
> in the `ml` branch (the "Series Suppression" toggle is currently a no-op in
|
||||||
|
> real calls — open item). **F7** — no widget _capability_ changes needed;
|
||||||
|
> custom actions bypass capability checks.
|
||||||
|
|
||||||
|
1. **Set the URL flags** on the widget iframe params (the `URLSearchParams` in
|
||||||
|
`CallEmbed`): `lotusCallState=1`, `lotusTransparent=1`/`lotusTheme=1`,
|
||||||
|
`lotusAudioInject=1` as desired. (Denoise sets `lotusDenoiseSource=1` + `lotusModel`/`lotusGate`/`lotusGateThreshold` in the `ml` tier.)
|
||||||
|
2. **Ack `io.lotus.call_state`**: add `listenAction('io.lotus.call_state', …)` —
|
||||||
|
without a reply the fork's sends time out every 250ms. Feed the payload into
|
||||||
|
`useCallSpeakers` and RETIRE its `contentDocument` DOM scrape.
|
||||||
|
3. **Send actions** via `this.call.transport.send(...)`:
|
||||||
|
`io.lotus.focus_participant` (replace `CallControl.focusCameraParticipant`’s
|
||||||
|
`.click()`), `io.lotus.inject_audio` (from the soundboard), `io.lotus.set_quality`
|
||||||
|
(from quality settings), `io.lotus.decorations` (push the MSC4133 decoration
|
||||||
|
map; resolve mxc→https first).
|
||||||
|
4. **#1 denoise cutover**: once verified, STOP injecting the `lotusDenoise()`
|
||||||
|
shim in `cinny/vite.config.js` and remove the `index.html` injection — the
|
||||||
|
fork now does denoise in-source. Keep shipping the `denoise/` assets (the
|
||||||
|
fork loads `./denoise/…` at runtime) until those move into the fork build.
|
||||||
|
5. Re-run `LOTUS_TESTING.md` §D for each feature; only then ship.
|
||||||
|
|
||||||
|
### 12.2 Holistic multi-agent review — outstanding follow-ups (non-blocking)
|
||||||
|
|
||||||
|
Four aspect-agents reviewed the whole fork. Criticals were fixed in-branch (the
|
||||||
|
denoise restart-silence/A7 bug; the `lotusDenoiseBase` code-load vector;
|
||||||
|
audio-inject opt-in gate; #6 rendering in the wrong component; #7 simulcast cap).
|
||||||
|
Remaining, deliberately deferred:
|
||||||
|
|
||||||
|
- **Denoise H2 (double-processing):** if cinny is set to `lotusDenoise=ml` while
|
||||||
|
ALSO still injecting its build-time `getUserMedia` shim, audio is denoised
|
||||||
|
twice. The #1 cutover MUST remove the cinny-side injection (it currently has
|
||||||
|
none injected into the iframe — keep it that way). Hard requirement, not code.
|
||||||
|
- **Denoise M1 (perf):** in-source uses non-SIMD `rnnoise.wasm`; the reference
|
||||||
|
preferred SIMD with detection. Perf-only; add SIMD detection later.
|
||||||
|
- **dtln/deepfilternet (F3): RESOLVED** — all four models
|
||||||
|
(rnnoise/speex/dtln/deepfilternet) are now implemented in
|
||||||
|
`lotusDenoiseProcessor.ts` (faithful port of cinny's `build/lotus-denoise.js`
|
||||||
|
pipeline). This also fixed a real bug (the gate worklet name was `noiseGate`;
|
||||||
|
correct is the hyphenated `noise-gate`) and added per-model sample rates
|
||||||
|
(DTLN 16 kHz, others 48 kHz), context `resume()`, and SIMD wasm selection.
|
||||||
|
Still needs live §D testing per model, and depends on cinny shipping the
|
||||||
|
DTLN (`denoise/workadventure/`) + DeepFilterNet (`denoise/deepfilternet/`)
|
||||||
|
asset trees (it already does).
|
||||||
|
- **Rebase-fragility (build agent MED):** the `CallViewModel` spotlight override
|
||||||
|
edits hot upstream lines (renamed `spotlightSpeaker$`→`autoSpotlightSpeaker$`).
|
||||||
|
For cheaper future rebases, refactor it into a `src/lotus/lotusSpotlight.ts`
|
||||||
|
wrapper that takes the upstream stream and returns the overridden one, leaving
|
||||||
|
upstream's definition byte-identical (a single import + two token swaps).
|
||||||
|
- **Denoise asset coupling (build agent HIGH):** the fork loads `./denoise/*`
|
||||||
|
shipped by cinny, not by the fork build (documented in the processor). Add an
|
||||||
|
integration smoke-check that `GET …/element-call/denoise/rnnoise.wasm` == 200,
|
||||||
|
and pin the `@sapphi-red/web-noise-suppressor` version both repos expect.
|
||||||
|
- **Unconditional effect registration (build agent LOW):** focus/audio-inject/
|
||||||
|
quality/decorations register widget handlers on every embedded call (true
|
||||||
|
no-ops for a non-Lotus host). Intentional; gate behind a coarse `lotus=1` flag
|
||||||
|
if strict zero-footprint is desired.
|
||||||
|
- **Privacy (security agent):** decoration/inject URLs accept any `https`; ideally
|
||||||
|
restrict to the homeserver media origin host-side. Call-state exposes
|
||||||
|
userId/deviceId/speaking to the (trusted, same-origin) host — documented.
|
||||||
|
|
||||||
|
**Nothing here blocks the §D live test — but every feature still needs it.**
|
||||||
|
|
||||||
|
### 12.3 Safe rollout when prod is the only test environment
|
||||||
|
|
||||||
|
Every Phase-2 feature is now **dormant by default** — with the flags cinny sets
|
||||||
|
today, the fork behaves identically to the parity build (`#1` was decoupled onto
|
||||||
|
`lotusDenoiseSource=1` so it no longer collides with the host's `lotusDenoise=ml`
|
||||||
|
shim). This enables a low-risk incremental rollout even without a staging env:
|
||||||
|
|
||||||
|
1. **Ship dormant first.** Publish the `lotus` branch (e.g. `0.20.1-lotus.1`),
|
||||||
|
bump cinny's pin, deploy. With no Lotus flags set / no Lotus actions sent,
|
||||||
|
this is upstream-equivalent (only inert, holistically-reviewed code runs).
|
||||||
|
"Testing" here = confirm a normal call still works.
|
||||||
|
2. **Enable ONE feature at a time**, each independently revertable:
|
||||||
|
- URL-flag features (#2 `lotusCallState`, #5 `lotusTransparent`/`lotusTheme`,
|
||||||
|
#1 `lotusDenoiseSource`): add the flag in `CallEmbed.getWidget`, deploy,
|
||||||
|
test that one feature, roll back just that flag if needed.
|
||||||
|
- Action features (#3,#4,#6,#7): wire the host send + (for #2) the
|
||||||
|
`listenAction` ack, gated on join (§12.1 F1).
|
||||||
|
3. **#1 denoise cutover is a coordinated 2-step** (do together): set
|
||||||
|
`lotusDenoiseSource=1` AND remove the `lotusDenoise()` shim injection +
|
||||||
|
`lotusDenoise=ml` param in cinny — otherwise audio is denoised twice.
|
||||||
|
Roll back = revert both.
|
||||||
|
4. Baseline is always upstream-equivalent, so any single feature can be disabled
|
||||||
|
by flipping its flag/send off without touching the rest.
|
||||||
|
|
||||||
|
**Blocker to step 1:** publishing the `lotus` branch needs a Gitea npm token
|
||||||
|
(the admin token used for the `0.20.1` parity publish was deleted). Either
|
||||||
|
provide a token for a manual `npm publish`, or stand up the Gitea Actions runner
|
||||||
|
|
||||||
|
- `GITEA_NPM_TOKEN` secret so a `v0.20.1-lotus.1` tag auto-publishes.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Lotus Chat — Open Bugs & Technical Debt
|
||||||
|
|
||||||
|
**Only OPEN and awaiting-verification items live here.** Resolved findings
|
||||||
|
(fixed-and-verified, false-positives, won't-fix) have been removed to keep this
|
||||||
|
actionable — the full history is in git. Items fixed in code but not yet
|
||||||
|
verified in a real environment are in **Needs Verification** below and have
|
||||||
|
step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
|
||||||
|
|
||||||
|
> Design rules for any fix here: follow the **Native-Cinny Law** and **TDS
|
||||||
|
> Design Law** in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Needs Verification — fixed in code, awaiting live testing
|
||||||
|
|
||||||
|
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||||
|
|
||||||
|
| ID | Item | File / area | Test |
|
||||||
|
| :--- | :------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------- |
|
||||||
|
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||||
|
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
||||||
|
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||||
|
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||||
|
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||||
|
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||||
|
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
||||||
|
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
||||||
|
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
||||||
|
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
||||||
|
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
||||||
|
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
||||||
|
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
||||||
|
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||||
|
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
||||||
|
|
||||||
|
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Element Call source-level items — now actionable via the fork
|
||||||
|
|
||||||
|
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
|
||||||
|
> self-build Element Call (`LotusGuild/element-call` →
|
||||||
|
> `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
|
||||||
|
> below are **fixed in the fork** — they are now ⚠️ awaiting **live
|
||||||
|
> verification** (`LOTUS_TESTING.md` §D2), not open work. See
|
||||||
|
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
|
||||||
|
> row once verified live.
|
||||||
|
|
||||||
|
The in-call participant grid is rendered **inside EC's app** — now editable source
|
||||||
|
(previously a prebuilt npm bundle we could only style around). Status of the items
|
||||||
|
from testing:
|
||||||
|
|
||||||
|
- **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
|
||||||
|
sends an `io.lotus.focus_participant` widget action that pins a participant in
|
||||||
|
EC's layout (coexisting with / overriding the screenshare spotlight); the old
|
||||||
|
`.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
|
||||||
|
- **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
|
||||||
|
cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
|
||||||
|
them on EC's participant video-tile avatars — not just our pre-join lobby roster.
|
||||||
|
- **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
|
||||||
|
(D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
|
||||||
|
LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
|
||||||
|
(re)publish, so reconnects keep denoise alive natively. The build-time
|
||||||
|
`getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
|
||||||
|
blast radius — everyone's mic; verify D2-1 carefully.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Open — Actionable
|
||||||
|
|
||||||
|
### Calls / Audio
|
||||||
|
|
||||||
|
- ~~**N127 — ML denoise shim is never injected in `vite dev`.**~~ **RESOLVED (dissolved by the A7 denoise cutover).** `vite.config.js` no longer injects a getUserMedia shim at all — the forked Element Call runs ML denoise in-source as a LiveKit `TrackProcessor` (activated by `lotusDenoiseSource=1`), so there is no build-time injection that could be missing in dev. Nothing to fix.
|
||||||
|
|
||||||
|
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
|
||||||
|
|
||||||
|
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
|
||||||
|
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
|
||||||
|
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
|
||||||
|
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
|
||||||
|
> a dedicated cross-system planning session with the homeserver owner. Capture
|
||||||
|
> full client console + a synapse-side trace for the same call before starting.
|
||||||
|
> **None of these are caused by the EC fork work** (the issues reproduce on the
|
||||||
|
> old build; the local mic/denoise path is unrelated to key distribution).
|
||||||
|
|
||||||
|
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
|
||||||
|
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
|
||||||
|
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}` —
|
||||||
|
firing **continuously** (many/sec). The client repeatedly tries to publish an
|
||||||
|
OTK at a key id the server already holds **with a different value**, i.e. the
|
||||||
|
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
|
||||||
|
the crypto outgoing-request loop and is the prime suspect for the downstream
|
||||||
|
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
|
||||||
|
to-device key events). _Investigate:_ device/key-store reset-or-restore
|
||||||
|
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
|
||||||
|
Synapse OTK bug. Repro signature: grep console for `already exists`.
|
||||||
|
**Extreme — planning session.**
|
||||||
|
|
||||||
|
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
|
||||||
|
`MissingKey: missing key at index N for participant @user`, `skipping decryption
|
||||||
|
due to missing key`, `MissingKey: key set not found for @user at index 0`, and
|
||||||
|
rust-crypto `WARN … Received an unexpected encrypted to-device event …
|
||||||
|
event_type="io.element.call.encryption_keys"`. EC distributes per-participant
|
||||||
|
media keys as **encrypted to-device `io.element.call.encryption_keys`** events;
|
||||||
|
these aren't being received/decrypted in order, so remote LiveKit audio/video
|
||||||
|
can't be decrypted — **this is the "friend's audio cuts out occasionally"
|
||||||
|
symptom.** Almost certainly downstream of **KE-1** (broken Olm sessions). Spans
|
||||||
|
EC's MatrixRTC E2EE + rust-crypto to-device + Synapse. **Extreme — planning
|
||||||
|
session.**
|
||||||
|
|
||||||
|
- **KE-3 — Timeline decryption error: missing `algorithm` field (HIGH).**
|
||||||
|
`Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg:
|
||||||
|
missing field 'algorithm' at line 1 column 138 …]`. A malformed/legacy
|
||||||
|
encrypted event (or a serialization mismatch in the RC SDK) that rust-crypto
|
||||||
|
can't parse. Lower frequency than KE-1/2 but a distinct decode-path failure —
|
||||||
|
capture the offending event id (`$SASBBzoqj…` seen) and inspect its raw content.
|
||||||
|
|
||||||
|
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH, reliability).**
|
||||||
|
`[MembershipManager] Network local timeout error while sending event, immediate
|
||||||
|
retry … AbortError: Restart delayed event timed out before the HS responded`,
|
||||||
|
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
|
||||||
|
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
|
||||||
|
call membership and missed leave events. May be partly **homeserver
|
||||||
|
responsiveness**; correlate with synapse latency/load. Include in the same
|
||||||
|
planning session since it shares the call-reliability + HS-interaction surface.
|
||||||
|
|
||||||
|
### Security & Privacy
|
||||||
|
|
||||||
|
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
|
||||||
|
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
|
||||||
|
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
|
||||||
|
|
||||||
|
### PWA / Offline / Notifications
|
||||||
|
|
||||||
|
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
|
||||||
|
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
|
||||||
|
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
|
||||||
|
|
||||||
|
### Dependencies & Build
|
||||||
|
|
||||||
|
- **`matrix-js-sdk` pinned to a Release Candidate** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
|
||||||
|
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
|
||||||
|
|
||||||
|
### Code Hygiene / DevEx
|
||||||
|
|
||||||
|
- **Automated test suite — 545 tests across 62 modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
|
||||||
|
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
||||||
|
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
||||||
|
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
|
||||||
|
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
|
||||||
|
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
|
||||||
|
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
|
||||||
|
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
|
||||||
|
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
|
||||||
|
|
||||||
|
### Big Projects
|
||||||
|
|
||||||
|
- **#5 — Seasonal themes & chat-background redesign.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
# Lotus Chat — Manual Testing Guide
|
||||||
|
|
||||||
|
**Generated:** June 2026
|
||||||
|
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||||
|
|
||||||
|
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||||
|
|
||||||
|
## Environment notes
|
||||||
|
|
||||||
|
- You push from your own machine; these commits are local on `lotus` until you do.
|
||||||
|
- Test the **web** build (LXC 106 / `code.lotusguild.org`) first; re-run the **call** + **poll** sections on the **desktop (Tauri)** build too, since CSP and the EC iframe behave differently there.
|
||||||
|
- Several call features need a **second participant** (second account on another device/browser, or a colleague). Items that need this are marked **👥 2 people**.
|
||||||
|
- A couple of call items need a **third room/call** in parallel — marked **👥👥**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commits covered
|
||||||
|
|
||||||
|
| Commit | Area |
|
||||||
|
| :--------- | :--------------------------------------------------------------------------- |
|
||||||
|
| `caf6318a` | Poll vote buttons → folds tokens (N4) |
|
||||||
|
| `c67aed01` | In-call incoming-call banner (#4b) |
|
||||||
|
| `4a875884` | Selectable ringtone (#4a) |
|
||||||
|
| `0394fce9` | EC iframe load watchdog + recovery UI; avatar decorations on call tiles (#3) |
|
||||||
|
| `d2946c00` | Upload retry/backoff, presence-on-unload, typed m.direct |
|
||||||
|
| `b7e1f89c` | Timeline/composer/emoji perf memoization |
|
||||||
|
| `c0f98672` | Upstream **Element Call 0.20.1** merge (regression sweep) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Calls — new ringtone + notification work (highest priority)
|
||||||
|
|
||||||
|
### A1. Ringtone selection — preview in Settings
|
||||||
|
|
||||||
|
**Steps**
|
||||||
|
|
||||||
|
1. Open **Settings → General**, scroll to the **Calls** section.
|
||||||
|
2. Find the new **Ringtone** dropdown (just above **Ringtone Volume**).
|
||||||
|
3. Select each option in turn: **Classic, Chime, Soft, Retro, Silent**.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Selecting **Classic** plays the existing `call.ogg` clip (cut off after a few seconds).
|
||||||
|
- **Chime / Soft / Retro** each play a short, distinct synthesized preview.
|
||||||
|
- **Silent** plays nothing.
|
||||||
|
- Changing **Ringtone Volume** then re-selecting a ringtone previews at the new volume.
|
||||||
|
- No console errors.
|
||||||
|
|
||||||
|
> ⚠️ **Known browser limitation:** the synthesized tones use WebAudio. If a preview is ever silent, click anywhere on the page once (a "user gesture") and retry — browsers suspend audio until the page has been interacted with. The Settings preview is _after_ a click so it should always sound; this note matters more for A3.
|
||||||
|
|
||||||
|
### A2. Ringtone selection persists
|
||||||
|
|
||||||
|
1. Set Ringtone to **Retro**, reload the app.
|
||||||
|
2. **Expected:** the dropdown still shows **Retro** (setting persisted).
|
||||||
|
3. Bonus: in devtools, set `localStorage.settings` to a bogus `ringtoneId` and reload → it should fall back to **Classic**, not break.
|
||||||
|
|
||||||
|
### A3. Incoming call uses the selected ringtone — 👥 2 people
|
||||||
|
|
||||||
|
**Setup:** Account A (you) and Account B in a **DM** or a **private (invite-only) group** room.
|
||||||
|
|
||||||
|
1. As A, pick a non-silent ringtone (e.g. **Chime**).
|
||||||
|
2. From B, **start a call** in that DM/room. Do **not** answer on A.
|
||||||
|
|
||||||
|
**Expected on A**
|
||||||
|
|
||||||
|
- The full-screen **Incoming Call** dialog appears (caller name, room avatar, Answer / Reject).
|
||||||
|
- The **selected ringtone loops** until you answer/reject/ignore (at the set volume).
|
||||||
|
- Answer → joins the call. Reject (DM) / Ignore (group) → dialog dismisses and ring stops.
|
||||||
|
- Set ringtone to **Silent** and repeat → dialog still appears, **no sound**.
|
||||||
|
|
||||||
|
### A4. In-call banner for a second incoming call — 👥👥 (the trickiest one)
|
||||||
|
|
||||||
|
**Setup:** You (A) already **in a call** in Room 1. Account B can call you in a **different** Room 2 (a DM or private group you share). Ideally a third account C, or B leaves Room 1's call first.
|
||||||
|
|
||||||
|
1. While A is **actively in Room 1's call**, trigger an incoming call to A from **Room 2**.
|
||||||
|
|
||||||
|
**Expected on A**
|
||||||
|
|
||||||
|
- **No** full-screen takeover. Instead a **compact banner appears in the top-right corner** with the caller's avatar, room name, "Incoming voice/video call", and **Answer / Reject (or Ignore)** buttons.
|
||||||
|
- It plays a **single soft ping**, _not_ a looping ring (so it doesn't talk over your active call).
|
||||||
|
- The banner does **not** cover your active call's controls/PiP in a way that blocks them.
|
||||||
|
- **Answer** → switches you into Room 2's call. **Reject/Ignore** → banner disappears.
|
||||||
|
- The banner auto-dismisses if the caller hangs up / the call times out.
|
||||||
|
|
||||||
|
**Also verify the no-op case:** while in Room 1's call, if a notification for **Room 1 itself** arrives, **nothing** should pop up (no banner, no dialog).
|
||||||
|
|
||||||
|
### A5. Camera focus during screenshare (#1) — 👥 2 people
|
||||||
|
|
||||||
|
**Setup:** You (A) and B in a call; B (or another participant) **sharing their screen**, and at least one person with **camera on**.
|
||||||
|
|
||||||
|
1. As A, open the **participant glance** (the stacked avatars / member list for the call) and click a participant who has their **camera on**.
|
||||||
|
2. In the menu, click **"Focus camera"**.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- The view switches to **spotlight** and **pins that person's camera tile**, overriding the auto-spotlighted screenshare.
|
||||||
|
- It **stays** on that camera (doesn't immediately snap back to the screenshare).
|
||||||
|
- If you pick someone with their camera **off**, it should at worst just toggle spotlight (graceful fallback), not error.
|
||||||
|
|
||||||
|
### A6. Avatar decorations on call tiles (#3) — 👥 2 people
|
||||||
|
|
||||||
|
**Setup:** A participant in the call has an **avatar decoration** set (Settings → Profile decoration).
|
||||||
|
|
||||||
|
1. Join a call with that participant.
|
||||||
|
2. Look at **our** participant roster / prescreen tiles (not the avatars rendered inside the Element Call video grid — those are EC's and out of scope).
|
||||||
|
|
||||||
|
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
|
||||||
|
|
||||||
|
### A7. EC iframe load watchdog + recovery UI (#EC, N96)
|
||||||
|
|
||||||
|
This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).
|
||||||
|
|
||||||
|
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
|
||||||
|
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
|
||||||
|
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
|
||||||
|
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
|
||||||
|
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Polls (N4) — render correctly on non-TDS themes
|
||||||
|
|
||||||
|
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
|
||||||
|
|
||||||
|
### B1. Poll renders on a default theme — ✅ PASS
|
||||||
|
|
||||||
|
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
|
||||||
|
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
|
||||||
|
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Each option is a clearly **bordered** button with visible rounded corners.
|
||||||
|
- A **radio circle** indicator is visible on the left of each option.
|
||||||
|
- Text, and (after votes) the percentage, are legible.
|
||||||
|
|
||||||
|
### B2. Voting + selected/progress state
|
||||||
|
|
||||||
|
1. **Vote** on an option.
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- The selected option shows a **filled accent border + filled radio**, and an **accent progress-bar fill** grows behind it proportional to the vote %.
|
||||||
|
- The percentage and total vote count update.
|
||||||
|
- Click again / pick another option → selection moves correctly (single-choice replaces; the bar redraws).
|
||||||
|
|
||||||
|
### B3. Multiple-choice poll
|
||||||
|
|
||||||
|
1. Create a poll allowing **multiple selections**.
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
|
||||||
|
- You can select **several** options; each shows its own progress fill.
|
||||||
|
|
||||||
|
### B4. Lotus Terminal theme regression — ✅ PASS
|
||||||
|
|
||||||
|
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
|
||||||
|
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Robustness / background behavior
|
||||||
|
|
||||||
|
### C1. Presence updates on tab close
|
||||||
|
|
||||||
|
1. Open the app, then **close the tab** (or quit the browser).
|
||||||
|
2. From another session/device, check your **presence** shortly after.
|
||||||
|
**Expected:** you go **offline/away** reliably (the unload now uses `fetch({keepalive})`). Previously this could be missed.
|
||||||
|
|
||||||
|
### C2. Upload retry on flaky network (best-effort)
|
||||||
|
|
||||||
|
1. In devtools → Network, set a throttle that drops/slows requests, or toggle Offline briefly **during** a file upload.
|
||||||
|
**Expected**
|
||||||
|
|
||||||
|
- A transient failure **retries** (up to 3×, with backoff) and the upload can still succeed once the network recovers.
|
||||||
|
- A genuine, permanent rejection (e.g. file too large / 4xx) still **fails fast** with the usual error — it should **not** spin retrying.
|
||||||
|
|
||||||
|
### C3. General timeline/composer perf (no functional regression)
|
||||||
|
|
||||||
|
The memoization changes are invisible if correct. Just confirm **nothing broke**:
|
||||||
|
|
||||||
|
- Open a busy room; scrolling, jump-to-latest, mark-as-read all still work.
|
||||||
|
- Composer: send a message, upload a file, share a location, pick an emoji and a sticker — all still work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Element Call 0.20.1 merge — regression sweep (👥 2 people)
|
||||||
|
|
||||||
|
The upstream bump changed EC's internals and DOM selectors; our call controls drive that iframe, so sweep them. In a live call with 2 people, confirm **each** of our control-bar buttons works:
|
||||||
|
|
||||||
|
- [ ] **Mic** mute/unmute (icon + actual audio)
|
||||||
|
- [ ] **Camera** on/off
|
||||||
|
- [ ] **Deafen / Sound** toggle (your deafen key too)
|
||||||
|
- [ ] **Screenshare** start/stop (and the "Share your screen?" confirm)
|
||||||
|
- [ ] **Screenshare audio** mute toggle
|
||||||
|
- [ ] **Fullscreen** toggle
|
||||||
|
- [ ] **⋮ More** menu → **Spotlight/Grid**, **Reactions**, **Settings** each open the right EC panel
|
||||||
|
- [ ] **End** call leaves cleanly
|
||||||
|
- [ ] **PTT** (push-to-talk) if enabled: hold key = transmit, release = mute; releasing on blur works
|
||||||
|
- [ ] **AFK auto-mute** if enabled: goes muted after the timeout
|
||||||
|
- [ ] **PiP** (picture-in-picture) mini window: drag, resize, fullscreen button, return-to-call; the "You muted" / "All muted" badges show on the right person
|
||||||
|
- [ ] **Denoise** (if ML noise suppression enabled): call audio still flows, no silence
|
||||||
|
|
||||||
|
If any control does nothing, that usually means an EC DOM selector changed — capture the console and tell me which button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D2. Element Call **fork** — Phase 2 feature sweep (👥 2 people) — `0.20.1-lotus.1`
|
||||||
|
|
||||||
|
> The whole EC iframe is now our **self-built fork** (`@lotusguild/element-call-embedded@0.20.1-lotus.1`).
|
||||||
|
> Five features are **active** (the host sets their flags / sends their actions); two ship **dormant**.
|
||||||
|
> **Confirm you're on the fork first:** EC iframe console prints `Element Call embedded-v0.20.1-lotus.1`
|
||||||
|
> (the old build prints `embedded-v0.20.1`). If it says the old version, the web deploy hasn't landed —
|
||||||
|
> the fork features won't be present, so don't test D2 yet.
|
||||||
|
> For non-dev testers, each item below also states the plain "✅ good if / ❌ tell us if" outcome.
|
||||||
|
|
||||||
|
### D2-1. Denoise **in-source** — survives reconnect (fixes A7) ⭐ highest risk (everyone's mic)
|
||||||
|
|
||||||
|
Flag: cinny sets `lotusDenoiseSource=1` when ML denoise is selected (the old build-time getUserMedia
|
||||||
|
shim is **removed**). This is the single change with the widest blast radius — test deliberately.
|
||||||
|
|
||||||
|
- [ ] **Audio flows, no silence** with ML denoise on (baseline, also §D line 204).
|
||||||
|
- [ ] **Reconnect (the A7 fix):** in a call with ML denoise on, kill network ~10 s (devtools → Offline)
|
||||||
|
so EC shows "Connection lost / Reconnect", then restore. **Mic still works AND still denoised**
|
||||||
|
afterward, **without** End+rejoin. _(This is the exact bug that was reintroduced then fixed; if it
|
||||||
|
regresses, mic dies on every reconnect.)_
|
||||||
|
- [ ] **Mic device switch mid-call** (Settings → change microphone): audio keeps working (same
|
||||||
|
`restart()` path as reconnect).
|
||||||
|
- [ ] **Mute → unmute** a few times: audio returns each time.
|
||||||
|
- [ ] **Each model** if the picker offers them: `rnnoise` (default), `speex`, `dtln`, `deepfilternet` —
|
||||||
|
each loads + denoises, no silence. (All four are in-source now; DTLN runs at 16 kHz, others 48 kHz.)
|
||||||
|
- [ ] **No double-processing:** audio isn't over-suppressed/artifacted (would mean the old shim is still
|
||||||
|
injected alongside the in-source engine).
|
||||||
|
- **Rollback if bad for everyone:** revert the cinny deploy commit (restores the shim + `@element-hq` parity).
|
||||||
|
|
||||||
|
### D2-2. Speaking + mute indicators from widget **events** (#2)
|
||||||
|
|
||||||
|
Flag: `lotusCallState=1`. cinny now reads speaker/mute state from `io.lotus.call_state` events instead of
|
||||||
|
scraping EC's DOM (DOM fallback retained). Overlaps **G1**.
|
||||||
|
|
||||||
|
- [ ] **Speaking glow** lights the **correct** person when they talk (you, then your friend).
|
||||||
|
- [ ] **PiP "All muted" / "You muted" badge** points at the right person and updates on mute/unmute.
|
||||||
|
|
||||||
|
### D2-3. Focus camera **during a screenshare** (#4 / A5)
|
||||||
|
|
||||||
|
Action: cinny sends `io.lotus.focus_participant` (the DOM `.click()` hack is gone). Overlaps **A5 / G2**.
|
||||||
|
|
||||||
|
- [ ] Person A screenshares; Person B camera on; **MemberGlance → Focus camera** on B → B's camera is
|
||||||
|
spotlighted **alongside/over** the shared screen (not ignored).
|
||||||
|
- [ ] Camera-**off** target = graceful (no error, no kick out of the screenshare).
|
||||||
|
|
||||||
|
### D2-4. In-call avatar decorations (#6) — **NEW, beyond A6**
|
||||||
|
|
||||||
|
Action: cinny pushes `io.lotus.decorations`. **A6 only covered the lobby roster** and called in-call EC
|
||||||
|
tiles out of scope — that's now in scope.
|
||||||
|
|
||||||
|
- [ ] A participant with a **Profile decoration** joins **camera off** → the decoration ring renders on
|
||||||
|
their **in-call video-tile avatar** (inside EC, not just the lobby), correctly sized/positioned.
|
||||||
|
- [ ] Decoration tracks the right person across grid/spotlight layout changes; disappears when they leave.
|
||||||
|
|
||||||
|
### D2-5. Native transparent background (#5)
|
||||||
|
|
||||||
|
Flag: `lotusTransparent=1` (native, replacing the injected `background:none !important`).
|
||||||
|
|
||||||
|
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
|
||||||
|
see-through, or layout breakage (also covered loosely by §D2 "looks right").
|
||||||
|
|
||||||
|
### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**
|
||||||
|
|
||||||
|
Flag: `lotusAudioInject=1`. A 🔔 **Soundboard** button now sits in the call controls bar (left group,
|
||||||
|
next to the chat button). Clips are user-uploadable and sync across your devices like emoji packs.
|
||||||
|
_Prereq:_ Settings → General → Calls → **Soundboard** must be ON (default on).
|
||||||
|
|
||||||
|
- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
|
||||||
|
It appears as a clip tile. (Too-big / too-many shows an error, doesn't crash.)
|
||||||
|
- [ ] **Plays into the call:** with a second person in the call, click a clip. **They hear it**, and
|
||||||
|
**you hear it locally** too. ✅ good if both hear it; ❌ tell us if only one side does.
|
||||||
|
- [ ] **Sync:** the uploaded clip shows up on your **other device**/session (account-data sync).
|
||||||
|
- [ ] **Delete:** the ✕ on a tile removes it (everywhere, after sync).
|
||||||
|
- [ ] **Off switch:** turn Settings → Calls → **Soundboard** off → the call-bar button disappears.
|
||||||
|
- [ ] Injecting a clip does **not** mute/interrupt your mic or anyone else's audio.
|
||||||
|
|
||||||
|
### D2-8. Call Quality Controls (#7 / P5-31) — 👥 2 people — **NEW**
|
||||||
|
|
||||||
|
Action: `io.lotus.set_quality`. User settings in **Settings → General → Calls** (Microphone Bitrate,
|
||||||
|
Screenshare Bitrate, Screenshare Framerate; all default **Auto**). Admin caps in **Room Settings →
|
||||||
|
General → Voice → Call Quality Caps**.
|
||||||
|
|
||||||
|
- [ ] **No regression at Auto:** with everything on **Auto**, calls/screenshare work exactly as before.
|
||||||
|
- [ ] **User cap takes effect:** set Microphone Bitrate to **32 kbps**, rejoin/continue a call — audio
|
||||||
|
still flows (thinner is fine). Set Screenshare Framerate to **15 fps** and share your screen — it
|
||||||
|
still shares. ❌ tell us if any setting kills audio/screenshare.
|
||||||
|
- [ ] **Applies mid-call:** changing a setting **during** a call takes effect without End+rejoin.
|
||||||
|
- [ ] **Room-admin cap (admin needed):** as a room admin, set **Max Microphone Bitrate = 64 kbps** in
|
||||||
|
Room Settings → Voice. A member whose user setting is higher (e.g. 256) should be **clamped to 64**
|
||||||
|
(best-effort/UX — this is client-side; hard server enforcement is a separate follow-up).
|
||||||
|
- [ ] Resetting a setting back to **Auto** removes the cap for the rest of the call.
|
||||||
|
|
||||||
|
> Soundboard + quality are no longer "dormant" — if either does nothing, grab the **EC iframe console**
|
||||||
|
> and check for `io.lotus.inject_audio` / `io.lotus.set_quality` rejections.
|
||||||
|
|
||||||
|
### D2-9. Call Permissions — HARD server-side, cross-client (👥 2 people, admin) — **NEW**
|
||||||
|
|
||||||
|
This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
|
||||||
|
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
|
||||||
|
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_
|
||||||
|
|
||||||
|
- [ ] **Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
|
||||||
|
**screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
|
||||||
|
- [ ] **Cross-client (the important one):** have someone join the **same room from stock Element / Element
|
||||||
|
X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
|
||||||
|
not just our client hiding a button.
|
||||||
|
- [ ] **Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
|
||||||
|
server-blocked for all clients; **microphones still work**.
|
||||||
|
- [ ] **⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
|
||||||
|
Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
|
||||||
|
(no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
|
||||||
|
on stock Element. ✅ good if the share drops within ~3–5 s; ❌ tell us if it keeps going.
|
||||||
|
- [ ] **Turning it back on** restores the ability to screenshare/camera (start a new share).
|
||||||
|
- [ ] **No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.
|
||||||
|
|
||||||
|
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
|
||||||
|
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backlog of previously-fixed-but-unverified items
|
||||||
|
|
||||||
|
> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** in `LOTUS_BUGS.md` / `LOTUS_TODO.md`. They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.
|
||||||
|
|
||||||
|
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
|
||||||
|
|
||||||
|
### E1. Composer toolbar touch targets (#7)
|
||||||
|
|
||||||
|
On a phone, open a room and the composer toolbar. Tap each button (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
|
||||||
|
**Expected:** every button is comfortably tappable (≥44×44px), no mis-taps hitting the wrong icon.
|
||||||
|
|
||||||
|
### E2. Room Settings — no horizontal overflow (#8)
|
||||||
|
|
||||||
|
On a narrow phone screen, open **Room Settings**.
|
||||||
|
**Expected:** the settings nav panel fills the full width; **no** horizontal scrollbar / sideways scrolling anywhere in the panel.
|
||||||
|
|
||||||
|
### E3. Modals go fullscreen on mobile (#9)
|
||||||
|
|
||||||
|
On a phone, open several dialogs: Leave Room, Create Room, Create Space, Invite User, Report (room/user/message), Edit History, Forward Message, Remind Me, Schedule Message, Device Verification, Poll Creator.
|
||||||
|
**Expected:** each opens **fullscreen** (no floating box, no rounded corners / max-width margins). On desktop the same modals should still be the normal centered boxes.
|
||||||
|
|
||||||
|
### E4. Composer not hidden by the keyboard (#10) — iOS Safari especially
|
||||||
|
|
||||||
|
On a phone (priority: **iOS Safari**), tap into the composer so the on-screen keyboard appears.
|
||||||
|
**Expected:** the composer input stays **visible above** the keyboard; the layout shrinks rather than the composer sliding under the keyboard.
|
||||||
|
|
||||||
|
### E5. Mobile "Saved Messages" access (Mobile Bookmarks)
|
||||||
|
|
||||||
|
On a phone, **inside a room**, open the room header **··· More Options** menu.
|
||||||
|
**Expected:** a **"Saved Messages"** item is present; tapping it opens the bookmarks panel. (This was the only in-room access point missing on mobile.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Visual / theming
|
||||||
|
|
||||||
|
### F1. Animated chat background — no flicker (#2)
|
||||||
|
|
||||||
|
Settings → set an **animated** chat background (e.g. anim-rain / anim-aurora / anim-stars). Watch the message text and composer while it animates.
|
||||||
|
**Expected:** smooth animation, **no flickering / shimmering** on message text or the composer, especially after scrolling. Note your GPU/browser if you see artifacts.
|
||||||
|
|
||||||
|
### F2. Background vs. Seasonal theme are mutually exclusive (#6)
|
||||||
|
|
||||||
|
In Settings → Appearance:
|
||||||
|
|
||||||
|
1. Pick a **chat background** → confirm any **seasonal theme** auto-switches off.
|
||||||
|
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
|
||||||
|
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).
|
||||||
|
|
||||||
|
### F3. Background / seasonal picker grid layout (N81)
|
||||||
|
|
||||||
|
In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
|
||||||
|
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G. Calls — additional unverified (👥 2 people)
|
||||||
|
|
||||||
|
### G1. PiP mute badges point at the right person (#12)
|
||||||
|
|
||||||
|
In a call with at least one other person, pop out the **Picture-in-Picture** mini window.
|
||||||
|
|
||||||
|
- **You** mute your own mic → a **"You"/muted badge appears bottom-left** (your status).
|
||||||
|
- A **remote** participant (or all of them) mutes → an **"All muted"** badge appears **top-right** (clearly about other people).
|
||||||
|
**Expected:** the bottom-left badge is **never** triggered by someone else muting — that was the original bug (it looked like your own mic was muted when it wasn't).
|
||||||
|
|
||||||
|
### G2. Full-screen camera broadcasts
|
||||||
|
|
||||||
|
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
|
||||||
|
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)
|
||||||
|
|
||||||
|
### G3. PTT badge renders on all themes (N53)
|
||||||
|
|
||||||
|
Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
|
||||||
|
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## H. Media / performance (needs a room with many images)
|
||||||
|
|
||||||
|
### H1. Lazy image decryption (P5-5 / MediaGallery)
|
||||||
|
|
||||||
|
Open a room / media gallery with **many images** (ideally encrypted). Scroll down through them.
|
||||||
|
**Expected:** images decrypt/load as they **approach the viewport**, not all at once on open; scrolling stays smooth and memory doesn't balloon. Off-screen images shouldn't all decode up front.
|
||||||
|
|
||||||
|
### H2. Thumbnail framing (P5-6)
|
||||||
|
|
||||||
|
Look at **tall portrait** images in the timeline and in the media gallery.
|
||||||
|
**Expected:** thumbnails are framed **center-top** (so faces/subjects at the top aren't cropped out); no awkward stretching. Opening the full-size viewer still shows the **whole** image (contain, not cropped).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I. Accessibility (needs a screen reader: VoiceOver / NVDA / TalkBack)
|
||||||
|
|
||||||
|
With a screen reader on, navigate message hover-actions and content and confirm each control **announces a meaningful label** (not "button" / blank):
|
||||||
|
|
||||||
|
- [ ] **Reaction** buttons announce the emoji + count (e.g. "thumbsup reaction, 3 people").
|
||||||
|
- [ ] **Edit history** button announces "View edit history".
|
||||||
|
- [ ] **Thread indicator** announces "View thread".
|
||||||
|
- [ ] **Reply** (jump to original) announces "Jump to original message".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J. Desktop / Tauri build only
|
||||||
|
|
||||||
|
### J1. Proactive update notifications (P5-40)
|
||||||
|
|
||||||
|
In the **desktop (Tauri)** build, with an update available, launch the app (and/or leave it running ~12h).
|
||||||
|
**Expected:** an in-app toast/badge alerts you that an update is available, without manually checking Settings. (Needs an actual newer release to point at.)
|
||||||
|
|
||||||
|
### J2. DTLN noise suppression sanity
|
||||||
|
|
||||||
|
In Settings → Calls, enable **ML noise suppression** with the **DTLN** model, then join a call.
|
||||||
|
**Expected:** your mic audio still flows (no silence/robotic dropouts) and background noise is reduced. Confirmed working earlier but flagged for a final real-call check; verify on **both** web and desktop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## K. Features — end-to-end unverified
|
||||||
|
|
||||||
|
### K1. Remind Me Later
|
||||||
|
|
||||||
|
On a message, **··· → Remind Me**, pick a short preset (the 20-min one, or wait one out).
|
||||||
|
**Expected:** when due, a Lotus toast fires linking to that message; the reminder then clears itself. Survives a reload while pending (stored in account data).
|
||||||
|
|
||||||
|
### K2. Advanced search filters (P4-9)
|
||||||
|
|
||||||
|
In message search: use the **sender picker** (instead of typing `from:@user`), the **date-range** quick presets (Today / Last week / Last month / Last year), and the **Has link** toggle.
|
||||||
|
**Expected:** each narrows results correctly and reflects in the search.
|
||||||
|
|
||||||
|
### K3. Notification content + click target (P5-20 partial)
|
||||||
|
|
||||||
|
Trigger a desktop/browser notification for a new message.
|
||||||
|
**Expected:** it shows the **real message body** (`username: message`, not "New inbox notification from…"); **clicking it** brings the window to front and navigates **directly to that message** (not just the inbox).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## L. Fixed — verify
|
||||||
|
|
||||||
|
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
|
||||||
|
|
||||||
|
**Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
|
||||||
|
|
||||||
|
**To verify:**
|
||||||
|
|
||||||
|
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
|
||||||
|
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
|
||||||
|
3. **Unmute** → the indicator should re-appear (capture re-acquired).
|
||||||
|
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
|
||||||
|
|
||||||
|
### L2. Maskable PWA icon (N108) — Android install
|
||||||
|
|
||||||
|
1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
|
||||||
|
2. Look at the **home-screen icon**.
|
||||||
|
|
||||||
|
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M. New features (this round)
|
||||||
|
|
||||||
|
### M1. Search: `has:image` / `has:file` / `has:video` filters
|
||||||
|
|
||||||
|
1. Open message search (in a room with shared images/files/videos in history).
|
||||||
|
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
|
||||||
|
- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
|
||||||
|
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
|
||||||
|
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.
|
||||||
|
|
||||||
|
### M2. Search: recent searches
|
||||||
|
|
||||||
|
1. Run a few different searches, then **clear the search box** and focus it.
|
||||||
|
|
||||||
|
**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).
|
||||||
|
|
||||||
|
### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment
|
||||||
|
|
||||||
|
1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
|
||||||
|
- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
|
||||||
|
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
|
||||||
|
- If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
|
||||||
|
- **Reset** clears it back to the theme default.
|
||||||
|
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
|
||||||
|
- Reload → the chosen accent **persists**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M4. Search: "Pinned only" filter
|
||||||
|
|
||||||
|
In message search, toggle the **Pinned** chip.
|
||||||
|
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.
|
||||||
|
|
||||||
|
### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment
|
||||||
|
|
||||||
|
Settings → Appearance → theme picker → try each of the 5 new themes.
|
||||||
|
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
|
||||||
|
|
||||||
|
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
|
||||||
|
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
|
||||||
|
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
|
||||||
|
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
|
||||||
|
delta + a `config.json` override) OR against **mozilla.org** with a real account.
|
||||||
|
|
||||||
|
### N1. OIDC login flow (the core test) — needs a MAS homeserver
|
||||||
|
|
||||||
|
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
|
||||||
|
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
|
||||||
|
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
|
||||||
|
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
|
||||||
|
|
||||||
|
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
|
||||||
|
|
||||||
|
### N2. Session persists across reload (token storage)
|
||||||
|
|
||||||
|
After N1, hard-refresh the page.
|
||||||
|
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
|
||||||
|
|
||||||
|
### N3. Token refresh (long-lived session)
|
||||||
|
|
||||||
|
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
|
||||||
|
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
|
||||||
|
|
||||||
|
### N4. Logout revokes at the issuer
|
||||||
|
|
||||||
|
Log out from Settings.
|
||||||
|
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
|
||||||
|
|
||||||
|
### N5. Account-management deep-link
|
||||||
|
|
||||||
|
Settings → Account.
|
||||||
|
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
|
||||||
|
|
||||||
|
### N6. Non-OIDC regression — password login unchanged
|
||||||
|
|
||||||
|
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
|
||||||
|
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority if you're short on time
|
||||||
|
|
||||||
|
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
||||||
|
2. **B1–B3** (polls on a default theme) — the confirmed visual bug.
|
||||||
|
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
|
||||||
|
4. **A7** false-positive check (normal joins don't show the error overlay).
|
||||||
|
5. Everything else.
|
||||||
@@ -1,280 +1,200 @@
|
|||||||
# Lotus Chat
|
# Lotus Chat
|
||||||
|
|
||||||
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1.
|
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
||||||
|
|
||||||
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
|
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Changes from upstream Cinny
|
## Licensing & Attribution
|
||||||
|
|
||||||
### Branding & Identity
|
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
||||||
|
|
||||||
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
|
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||||
- App title changed from "Cinny" to "Lotus Chat" throughout
|
|
||||||
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
|
|
||||||
- Logo in About dialog and Auth page replaced with official Lotus.png
|
|
||||||
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
|
|
||||||
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
|
|
||||||
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
|
|
||||||
- `manifest.json` updated with Lotus name, description, and branding colors
|
|
||||||
|
|
||||||
### LotusGuild Terminal Design System (TDS) v1.2
|
|
||||||
|
|
||||||
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
|
||||||
|
|
||||||
**Dark mode** (`LotusTerminalTheme`):
|
|
||||||
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
|
|
||||||
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
|
|
||||||
- Monospace font stack, terminal-style scrollbars
|
|
||||||
- Custom hex-grid and circuit-board CSS background patterns
|
|
||||||
- Matrix-style boot messages on the welcome page (press Escape to skip)
|
|
||||||
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
|
|
||||||
|
|
||||||
**Light mode** (`LotusTerminalLightTheme`):
|
|
||||||
- Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
|
|
||||||
- No CRT effects (scanlines, vignette disabled)
|
|
||||||
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
|
|
||||||
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
|
|
||||||
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
|
|
||||||
|
|
||||||
**Chat Backgrounds** (20+ custom patterns, all TDS-aware):
|
|
||||||
- Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
|
|
||||||
- Chevron, polka dots, triangles, plaid
|
|
||||||
- All patterns use CSS custom properties — adapt to both TDS dark and light themes
|
|
||||||
- Settings toggle for showing per-message sender profiles
|
|
||||||
|
|
||||||
### Voice / Video Call Improvements
|
|
||||||
|
|
||||||
- **Element Call 0.19.4**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
|
|
||||||
- **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
|
|
||||||
- **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
|
|
||||||
- **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
|
|
||||||
- **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
|
|
||||||
- **Push to Talk (PTT)**:
|
|
||||||
- Configurable keybind (default: Space) via Settings > General > Calls
|
|
||||||
- Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
|
|
||||||
- Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
|
|
||||||
- Listens on both main window and EC iframe `contentWindow` for reliable key capture
|
|
||||||
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
|
|
||||||
- **Mic state preservation**: when enabling PTT mode mid-call, the user's previous mic state is saved and restored when PTT is disabled — prevents unwanted unmute if the user had manually muted before switching to PTT.
|
|
||||||
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
|
|
||||||
- **Call button scoping**: The upstream Cinny 4.12.1 call button (voice + video dropdown) is restricted to DMs and private group chats only. Specifically: direct messages, or invite-only rooms that have no `m.space.parent` state event (i.e. not a space/guild text channel). Public rooms and space channels are excluded to prevent accidental mass-notifications. `Room.tsx` switches to CallView layout when a call embed is active in the current room.
|
|
||||||
- **Poll display**: `m.poll.start` events (both stable Matrix 1.7 `m.poll` content key and MSC3381 unstable `org.matrix.msc3381.poll.start`) render as read-only poll cards inside the standard message bubble — question and answer options shown. Registered as top-level event renderers AND inside the `EncryptedContent` callback so encrypted polls also display after decryption. "Open in Element to vote" note displayed. Implemented in `PollContent.tsx`.
|
|
||||||
- **Deleted message placeholder**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events no longer disappear from the timeline. Instead they reach the existing `RedactedContent` component (trash icon + italic "This message has been deleted" with reason if provided), matching Element, FluffyChat, Commet, and Nheko behaviour. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
|
||||||
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
|
|
||||||
- **Call embed positioning**: `useCallEmbedPlacementSync` uses `getBoundingClientRect()` (not `offsetTop/Left`) for accurate viewport-relative coordinates on the `position:fixed` container. Position is synced immediately on mount via `useEffect` in addition to the ResizeObserver, so the embed is placed correctly the instant the call view renders. The `[pipMode, callVisible]` effect in `CallEmbedProvider` only clears pip-specific styles when actually exiting pip mode — no longer clobbers the position set by `syncCallEmbedPlacement` on every `callVisible` toggle.
|
|
||||||
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
|
|
||||||
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
|
|
||||||
|
|
||||||
### Moderation
|
|
||||||
|
|
||||||
- **Report Room**: A "Report Room" option in the room header menu (⋮) allows users to report a room to homeserver admins with a reason and abuse category (Spam / Harassment / Inappropriate Content / Other). Calls `POST /_matrix/client/v3/rooms/{roomId}/report` (MSC4151, confirmed supported on matrix.lotusguild.org). Implemented in `ReportRoomModal.tsx` with loading/success/error states.
|
|
||||||
- **Policy List / Ban List Viewer (MSC2313)**: A "Policy Lists" tab in Room / Space Settings (admin-only, power-level gated) shows all subscribed `m.policy.rule.*` rooms and their contents — banned users, banned rooms, and banned servers — each with entity, reason, and recommendation fields. Subscribe (join the policy room) and Unsubscribe (leave) actions are provided. Enforcement remains solely with the Draupnir bot; this UI is a read-only complement.
|
|
||||||
|
|
||||||
### Messaging Enhancements
|
|
||||||
|
|
||||||
- **Rich room topics**: Room topics that contain formatted text (bold, links, italic) are now rendered with full HTML formatting. Falls back to plain text if no `formatted_body` is present. Activates when any room admin sets a formatted topic.
|
|
||||||
- **Edit history viewer**: Clicking the "edited" label on any edited message opens a modal showing every prior version with timestamps. Fetches all `m.replace` relations for the event and displays them oldest-to-newest. Previously the "edited" label was visible but unclickable. E2EE fix: the "Original" entry now uses `getClearContent()` (bypasses the replacing-event chain, returns the decrypted pre-edit body) instead of `event.content` which is still raw ciphertext for encrypted messages — fixes "(no text)" shown for almost all E2EE message originals.
|
|
||||||
- **Inline GIF preview**: Giphy and Tenor share links sent as plain text auto-embed as animated GIFs inline in the timeline. URL patterns are detected client-side; the image is fetched via the homeserver's `/_matrix/media/v3/preview_url` proxy (no direct contact with Giphy/Tenor from the client). Rendered as `<img loading="lazy">` — respects the existing URL preview enabled/disabled setting.
|
|
||||||
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
|
|
||||||
- **Message forwarding**: Forward any message to any room from the message context menu.
|
|
||||||
- **Draft persistence**: Unsent message drafts survive page reload via `localStorage` (`draft-msg-<roomId>`). Jotai in-memory atom is primary; localStorage is used as fallback on reload and cleared on send.
|
|
||||||
- **Message search date range**: From/To date pickers in the search filter bar. Sends `from_ts`/`to_ts` epoch ms to the Matrix `/search` endpoint. Chip shows active range with X to clear.
|
|
||||||
- **Image/video captions**: Caption text field on image and video upload — sent as a single event with the media.
|
|
||||||
- **Location sharing**: Map embed view for incoming location events + static share button. Renders `m.location` events inline with a map tile.
|
|
||||||
- **Deleted message placeholders**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events render as "This message has been deleted" with reason (if provided) rather than disappearing. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
|
||||||
- **Message bookmarks / saved messages**: Right-click any message → "Bookmark" / "Remove Bookmark". A star icon in the sidebar nav opens `BookmarksPanel.tsx` — a right-side panel listing all saved messages with room name, preview text, relative timestamp, filter input, "Jump to message" deep-link, and individual remove buttons. Stored in `io.lotus.bookmarks` account data (max 500 entries); syncs across devices. Implemented in `src/app/features/bookmarks/BookmarksPanel.tsx` + `src/app/hooks/useBookmarks.ts`.
|
|
||||||
- **Message scheduling (MSC4140)**: Clock button next to send opens `ScheduleMessageModal.tsx` with a message textarea and datetime picker. Messages sent via the MSC4140 delayed events API (`org.matrix.msc4140`), confirmed supported on `matrix.lotusguild.org`. A collapsible tray above the composer lists pending scheduled messages with Cancel buttons. Utilities in `src/app/utils/scheduledMessages.ts`.
|
|
||||||
- **Richer link preview cards**: `UrlPreviewCard.tsx` renders domain-specific cards for 13 sites: YouTube (thumbnail + ▶ play overlay), Vimeo, GitHub (repo parse), Twitter/X (tweet text + media parse), Reddit (subreddit + upvotes + comments), Spotify (artwork), Twitch (LIVE badge + game), Steam, Wikipedia, Discord (server invite), npm, Stack Overflow, and IMDb (poster). Generic cards gain a favicon from Google's S2 service. Cards that produce no renderable content are suppressed.
|
|
||||||
- **File upload compression (opt-in)**: JPEG and PNG files in the upload preview show a "Compress" checkbox. When checked, a Canvas API call (`toBlob(..., 'image/jpeg', 0.82)`) compresses the image client-side. Original and compressed sizes are shown side-by-side. Compression is strictly opt-in — unchecked by default, skipped for GIF/SVG/WebP.
|
|
||||||
|
|
||||||
### Room Customization
|
|
||||||
|
|
||||||
- **Personal room name overrides**: Right-click any room in the sidebar → "Rename for me…" to set a local display name visible only to you. Other members see the original name unchanged. A small pencil icon marks rooms with a custom local name. Stored in Matrix account data (`io.lotus.room_names`). Uses `io.lotus.room_names` account data key (based on MSC4431).
|
|
||||||
- **Export room history**: Room Settings → Export tab. Supports Plain Text, JSON, and HTML formats with optional start/end date range filters. Paginates backwards via `mx.paginateEventTimeline()` with a live progress counter. E2EE-aware — events that failed decryption are skipped rather than exported as garbled ciphertext. Downloads via `Blob` + `<a download>`. Implemented in `src/app/features/room-settings/ExportRoomHistory.tsx`.
|
|
||||||
- **Room activity / mod log**: Room Settings → Activity tab. Filterable log of `m.room.member` (join/leave/kick/ban/unban/invite), `m.room.power_levels`, `m.room.name`, `m.room.topic`, `m.room.avatar`, and `m.room.server_acl` events. Human-readable descriptions, relative timestamps, type-filter dropdown, Load More pagination, and auto-paginate on mount. Implemented in `src/app/features/room-settings/RoomActivityLog.tsx`.
|
|
||||||
- **Server ACL editor**: Room Settings → Server ACL tab. Reads and writes `m.room.server_acl` state events. Editable allow and deny server lists with wildcard pattern validation (`*.example.com`). "Allow IP literal addresses" toggle. Read-only view shown to users without the required power level. Implemented in `src/app/features/room-settings/RoomServerACL.tsx`.
|
|
||||||
- **Room stats / insights**: Room Settings → Insights tab (not the default tab). Derives all statistics from the local timeline cache only; a disclaimer banner clarifies this. Shows top 5 active members (bar chart), top 5 reactions (emoji chips), media breakdown (images/videos/audio/files tiles), and a 24-hour activity heatmap (CSS bar chart). Implemented in `src/app/features/room-settings/RoomInsights.tsx`.
|
|
||||||
|
|
||||||
### Per-Message Read Receipts
|
|
||||||
|
|
||||||
Full per-message read receipt system — shows who has read each message directly in the timeline.
|
|
||||||
|
|
||||||
**Architecture:**
|
|
||||||
- `useRoomReadPositions(room)` hook — computes a `Map<eventId, userId[]>` from all joined members' `room.getEventReadUpTo()` positions. Subscribes to `RoomEvent.Receipt` for live updates (debounced at 150ms to batch burst updates from mass-read events).
|
|
||||||
- `nearestRenderableId(liveEvents, evtId)` — receipts can land on reaction/edit events that `RoomTimeline` skips (renders `null`). This walks backwards from the receipt event through the live timeline until it finds a non-reaction/non-edit event to attach to.
|
|
||||||
- `ReadPositionsContext` — React context providing the positions map from `RoomTimeline` down to all `Message` instances without prop drilling.
|
|
||||||
- `ReadReceiptAvatars` component — renders a pill-shaped row of overlapping `StackedAvatar` circles (24px, `SurfaceVariant` outline) below messages with readers. Pill uses `color.SurfaceVariant.Container` background for visibility on any wallpaper. Max 5 avatars shown + `+N` overflow count. Avatar fallback uses `colorMXID(userId)` for distinctive per-user color.
|
|
||||||
- Clicking the pill opens the **"Seen by" modal** (`EventReaders`) listing all readers with their avatar, display name, and a formatted read timestamp ("Today at 3:42 PM", "Yesterday at 10:15 AM", "May 14 at 9:00 AM"). Timestamps use `room.getReadReceiptForUserId(userId)?.data.ts` and respect the user's 24-hour clock setting.
|
|
||||||
- Authenticated media (`mxcUrlToHttp` utility) used for all avatar loads, matching the correct Lotus utility signature.
|
|
||||||
|
|
||||||
### Delivery Status Indicators
|
|
||||||
|
|
||||||
Own messages display a small status marker below the message content (when no read receipts are visible yet):
|
|
||||||
- `⟳` — message is being sent / encrypting
|
|
||||||
- `✓` — message confirmed sent (local echo)
|
|
||||||
- `✕` — message failed to send (shown in red; orange glow in TDS mode)
|
|
||||||
- Status hidden once the server confirms receipt (`status === null`) — read receipts take over at that point
|
|
||||||
|
|
||||||
### URL Preview Cards (TDS)
|
|
||||||
|
|
||||||
URL preview cards (`UrlPreviewCard`) styled for terminal mode:
|
|
||||||
- Dark transparent background with cyan border-left accent (Anduril Orange)
|
|
||||||
- Link text in cyan, hover switches to orange with glow
|
|
||||||
- Light TDS variant: off-white background with blue accent
|
|
||||||
|
|
||||||
### Reaction Chips (TDS)
|
|
||||||
|
|
||||||
Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]` selector:
|
|
||||||
- Unselected: `rgba(0,212,255,0.06)` background, cyan border
|
|
||||||
- Hover: brighter background + box-shadow glow
|
|
||||||
- Own reaction (aria-pressed=true): orange tint `rgba(255,107,0,0.12)`, orange border
|
|
||||||
- Light TDS: equivalent blue/orange variants
|
|
||||||
|
|
||||||
|
|
||||||
### DM Call Improvements
|
|
||||||
|
|
||||||
|
|
||||||
- **Incoming call ring**: DM calls trigger a ring tone with Answer/Decline UI. 30-second auto-dismiss if unanswered. Implemented in `Room.tsx` and `RoomViewHeader.tsx`.
|
|
||||||
|
|
||||||
### Room Customization
|
|
||||||
|
|
||||||
- **Room emoji prefix**: A leading emoji in a room name (e.g. 🎮 general) renders at 1.15× size in the sidebar for visual impact. Matrix room names already support Unicode — this is purely a rendering enhancement in `RoomNavItem.tsx`. All three room-name inputs (Create Room, Room Settings, "Rename for me…" dialog) now include a 😊 emoji picker button that prepends the selected emoji to the name field.
|
|
||||||
|
|
||||||
### Presence
|
|
||||||
|
|
||||||
- **Discord-style presence selector**: Clicking your avatar in the bottom-left sidebar opens a popout with five status options — Online (green), Idle (yellow), Do Not Disturb (red, broadcasts `unavailable` with `status_msg: 'dnd'`), Invisible (grey outline, broadcasts `offline`), and Auto (activity-tracking, the original behaviour). The selected status persists across reloads via the settings atom. A colored badge on the avatar reflects the current status at a glance. `usePresenceUpdater` short-circuits immediately for manual modes; full idle-timer and visibility-change logic only runs in Auto mode. Settings also exposed via `src/app/state/settings.ts` (`presenceStatus` field).
|
|
||||||
- **Custom status message**: Set a short status text (up to 64 characters) with an emoji picker, shown below your display name in member lists and presence displays. Accessible via Settings → Account → Profile. Includes an **auto-clear timer** (options: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days) — after the timer expires, the status is automatically cleared by setting `status_msg: ''` via `mx.setPresence`. A character counter (shown when ≥ 56/64 chars) prevents overflow. Implemented in `src/app/features/settings/account/Profile.tsx`.
|
|
||||||
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
|
|
||||||
- **Presence avatar border ring**: A 2px colored `outline` ring on user avatars throughout the app shows presence at a glance — green (online), yellow (idle), red (DND), no ring (offline). Implemented as `PresenceRingAvatar` component (`src/app/components/presence/PresenceRingAvatar.tsx`) using `React.cloneElement` to inject `outline` + `outlineOffset` directly onto the child `Avatar` element — the ring follows the avatar's actual `border-radius` regardless of shape. Applied to: message timeline sender avatars, members drawer, @mention autocomplete, and inbox notification senders.
|
|
||||||
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
|
|
||||||
- **Extended profile fields (MSC4133)**: Settings → Account → Profile includes Pronouns (`m.pronouns`) and Timezone (`m.tz`) fields, saved via MSC4133 `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`. Both fields are displayed in user profile panels. Implemented via `src/app/hooks/useExtendedProfile.ts`.
|
|
||||||
- **User local time in profile**: When a user has `m.tz` set, their profile panel shows a clock icon, their current local time, and the timezone abbreviation (e.g. EST, JST). Updates every 60 seconds. Respects the viewer's `hour24Clock` setting. Implemented via `src/app/hooks/useLocalTime.ts`.
|
|
||||||
|
|
||||||
### UX & Composer
|
|
||||||
|
|
||||||
- **Message length counter**: A muted character counter appears just left of the send button while typing, disappearing when the composer is empty. Resets on room switch.
|
|
||||||
- **Quick emoji reactions on hover**: The 3 most-recently-used emoji reactions appear directly in the message hover toolbar (between the emoji-board button and Reply), so reacting requires a single click rather than opening the 3-dots menu first. Clicking a quick-reaction also closes any open emoji picker. Powered by `useRecentEmoji` sourced from Matrix account data.
|
|
||||||
- **In-app notification toasts**: When a message or invite notification fires and the browser window is focused, a slim TDS-styled toast card slides in from the bottom-right instead of triggering an OS notification. Card shows: 24px avatar (initials fallback), sender name in orange, truncated message body, room name, × dismiss, 4 s auto-dismiss. Clicking navigates directly to the correct room (DM or home path) or the invites inbox. OS notifications are unchanged when the window is not focused. Implemented in `src/app/features/toast/LotusToastContainer.tsx` + `src/app/state/toast.ts`.
|
|
||||||
- **Collapsible long messages**: Messages exceeding ~20 lines are auto-collapsed with a "Read more ↓" button. Click to expand inline; a "Collapse ↑" button re-folds. Threshold (in lines) configurable in Settings → Appearance. Uses CSS `max-height` + `overflow: hidden` — works correctly with code blocks and embedded media. Respects `prefers-reduced-motion`.
|
|
||||||
- **Message send animation**: Own sent messages fade and scale into the timeline (0.15 s ease-out: `scale(0.97)→scale(1)`, `opacity 0.4→1`). Incoming messages are unaffected. Respects `prefers-reduced-motion`.
|
|
||||||
- **Right-click room context menu**: Expanded sidebar room context menu — **Mute** now opens a duration submenu (15 min / 1 hr / 8 hr / 24 hr / Indefinite) with auto-restore after the selected window; **Copy Room Link** copies the `matrix.to` URL with a "Copied!" flash; **Mark as Read** marks the room read to the latest event; plus Leave Room and Room Settings shortcuts.
|
|
||||||
- **Unverified device warning**: `warnOnUnverifiedDevices` setting (default off). When enabled via Settings → General → Privacy, a warning banner appears above the composer in encrypted rooms that contain unverified devices, showing the count. Sending is never blocked — the banner is informational only. Uses the existing `useUnverifiedDeviceCount()` hook.
|
|
||||||
- **Sidebar room filter**: A search-icon input at the top of the Home and DMs sidebar tabs filters rooms by display name in real time. Clears on tab switch. Styled to match the members-drawer search bar (`size="400"`, search prefix icon).
|
|
||||||
- **DM last message preview**: Each DM row in the sidebar shows a truncated message body (48 chars) and relative timestamp (`Xm`, `Xhr`, `Yesterday`, `D MMM`) below the room name, sourced reactively from `useRoomLatestRenderedEvent`. Encrypted rooms show "Encrypted message" only on actual decryption failure.
|
|
||||||
- **Room sort order**: Sort icon in the Rooms sidebar header lets users sort non-space rooms by Recent Activity (default), A→Z, or Unread First. Persists via `homeRoomSort` setting.
|
|
||||||
- **Favorite rooms**: Right-click any room → "Add to Favorites" / "Remove from Favorites". Favorited rooms (using the standard Matrix `m.favourite` tag) appear in a collapsible "Favorites" section above the main room list on the Home tab. Syncs across devices via account data.
|
|
||||||
- **Poll creation**: Polls can be created directly from the composer — `Icons.OrderList` button opens a modal with question field, 2–10 answer options (add/remove), and Single/Multiple choice toggle. Sends a stable `m.poll.start` event. (Poll display & voting were already supported.)
|
|
||||||
- **Voice message playback speed**: `0.75×` → `1×` → `1.5×` → `2×` speed toggle pill on voice message player — cycles on click via `playbackRate` on the `<audio>` element.
|
|
||||||
- **Invite link + QR code**: Room settings → General shows a "Share Room" tile with the `matrix.to` invite URL and a QR code. The Invite modal also has a `⊞` toggle button showing a QR panel when clicked. Both use `api.qrserver.com` (added to CSP on LXC 106).
|
|
||||||
- **Private read receipts**: Settings → General → Privacy — "Private Read Receipts" toggle. When on, sends `m.read.private` instead of `m.read` so other room members can't see when you've read messages.
|
|
||||||
- **Media gallery**: A right-side drawer (photo icon in room header, Desktop only) showing Images | Videos | Files tabs. Reads already-decrypted timeline events — works in E2EE rooms. Encrypted-blob images show a lock-icon placeholder. Load More paginates backwards via `mx.paginateEventTimeline()`.
|
|
||||||
- **Knock-to-join**: When a room's join rule is `knock`, RoomIntro shows "Request to Join" (calls `mx.knockRoom()`) with "Request sent" pending state. Room admins see a "Pending Requests" section in the members drawer with Approve / Deny buttons.
|
|
||||||
- **Code syntax highlighting** (TDS mode): Fenced code blocks in messages highlight keywords (cyan), strings (green), numbers (orange), comments (italic dim), function names (purple) using inline `--lt-accent-*` CSS variables. Custom tokenizer in `syntaxHighlight.ts` — supports JS/TS/JSX/TSX, Python, Rust. Falls back to ReactPrism for other languages.
|
|
||||||
|
|
||||||
### Settings (Appearance)
|
|
||||||
|
|
||||||
- **Animated Chat Backgrounds**: Five CSS-only animated wallpapers added to the background picker — Digital Rain (two-layer vertical stripe scroll with parallax), Star Drift (three-layer radial-gradient star field drifting diagonally), Grid Pulse (neon grid lines expanding/contracting via `backgroundSize` keyframe), Aurora Flow (four radial gradient ellipses sweeping across a 200% canvas), Fireflies (three layers of glowing dots drifting). All use vanilla-extract `keyframes()` — no canvas, GPU-composited. Respects `prefers-reduced-motion: reduce` (animation stripped at call time). "Pause Background Animations" toggle in Settings → Appearance provides an in-app override. Implemented in `src/app/styles/Animations.css.ts` + `src/app/features/lotus/chatBackground.ts`.
|
|
||||||
- **Glassmorphism Sidebar**: Settings → Appearance toggle (off by default). When enabled, the left sidebar becomes semi-transparent (`background: rgba(3,5,8,0.55)`) with `backdrop-filter: blur(12px)` so chat background patterns show through as a frosted glass effect. Fix: the active chat background is mirrored onto `document.body` via `useEffect` in `SidebarNav.tsx` so the blur has content to work through (previously the sidebar was a flex sibling with nothing physically behind it). Implemented as a vanilla-extract `SidebarGlass` class applied to the `<Sidebar>` container in `SidebarNav.tsx`.
|
|
||||||
|
|
||||||
|
|
||||||
- **Night Light / Blue Light Filter**: Warm orange overlay (`rgba(255,140,0,N%)`) across the entire UI. Toggle + intensity slider (5–80%) in Settings → Appearance. `position:fixed; pointer-events:none; z-index:9998`. Persists across sessions.
|
|
||||||
|
|
||||||
### Notification Enhancements
|
|
||||||
|
|
||||||
- **Custom notification sounds**: `messageSoundId` / `inviteSoundId` settings select per-category notification sound (`notification.ogg`, `invite.ogg`, `call.ogg`, or None). Settings → Notifications expands the sound toggle with Message Sound + Invite Sound selects and ▶ preview buttons. Shared `notificationSounds.ts` module.
|
|
||||||
- **Notification quiet hours**: `quietHoursEnabled` / `quietHoursStart` / `quietHoursEnd` settings suppress all desktop notifications and sounds during a configured time window. Handles overnight spans (e.g. 23:00–08:00). Settings → Notifications: Quiet Hours card with toggle + start/end time pickers.
|
|
||||||
- **Full push rule editor**: Settings → Notifications → Advanced Push Rules section. Covers override, room, sender, and underride rule kinds. Each row has a human-readable label for built-in rules, an enable/disable toggle, and a delete button for custom rules. An add-rule form at the bottom of the room and sender sections lets users create new per-room or per-sender push rules by entering the room/user ID.
|
|
||||||
|
|
||||||
### Calls (Extended)
|
|
||||||
|
|
||||||
- **Push-to-Deafen**: Press `M` during a call to toggle speaker mute (deafen). Configurable in Settings → General → Calls alongside the PTT key. Skips editable elements; guards `e.repeat`; uses `el.ownerDocument.body` for iframe safety.
|
|
||||||
- **TDS typing indicator dots**: When Lotus Terminal mode is active, the animated typing indicator dots turn TDS orange (`var(--lt-accent-orange)`) via `color: currentColor` inheritance.
|
|
||||||
|
|
||||||
### Server Integration
|
|
||||||
|
|
||||||
- **Server support contact (MSC1929)**: Settings → Help & About displays the homeserver admin contact fetched from `/.well-known/matrix/support`. Shows the admin's Matrix ID and a link to the support page when the homeserver has configured this endpoint. Degrades gracefully when not configured (section is hidden on 404 or network error). In TDS mode the contact text and link render in `--lt-accent-cyan`. Implemented in `src/app/features/settings/about/About.tsx`.
|
|
||||||
- **Server notices**: Rooms of type `m.server_notice` (system messages from the homeserver) now render with a distinct "Server Notice" `<Chip variant="Warning">` badge in the room header and a disabled composer showing "This is a server notice room — you cannot send messages here." Previously indistinguishable from regular DMs. Badge in `src/app/features/room/RoomViewHeader.tsx`; composer guard in `src/app/features/room/RoomInput.tsx`.
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
|
|
||||||
- **Authenticated media**: All avatar/media loads use `mxcUrlToHttp(mx, mxcUrl, useAuthentication, w, h, 'crop')` from `../../utils/matrix` — the Lotus utility that handles MSC3916 authenticated media. (Upstream Cinny uses the SDK method with incorrect argument order for authenticated endpoints.)
|
|
||||||
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
|
|
||||||
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
|
|
||||||
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
|
|
||||||
- **URL preview default in encrypted rooms**: `encUrlPreview` default changed from `false` to `true` in `src/app/state/settings.ts`. A security note is shown next to the toggle in Settings → General explaining that the homeserver fetches the URL (and sees it) but not the message content.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build
|
## Features
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
|
||||||
|
- See who has read each message, and track delivery status (sending / sent / failed)
|
||||||
|
- Bookmark any message and revisit saved messages from the sidebar
|
||||||
|
- Schedule messages to send at a specific time
|
||||||
|
- Click "edited" on any message to see the full edit history
|
||||||
|
- Drafts are saved automatically and survive page reloads
|
||||||
|
- Long messages collapse automatically — click "Read more" to expand
|
||||||
|
- Forward messages to other rooms
|
||||||
|
- Create and view polls directly in chat
|
||||||
|
- Share your location with an inline map embed
|
||||||
|
- Add captions to image and video uploads
|
||||||
|
- Optionally compress images before uploading — shows before/after file sizes
|
||||||
|
- GIF links from Giphy and Tenor auto-preview inline
|
||||||
|
- Search for and send GIFs from a built-in GIF picker
|
||||||
|
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||||
|
- Search messages with a date range filter
|
||||||
|
- Room topics support rich formatting (bold, links, italics)
|
||||||
|
- Deleted messages show a placeholder instead of disappearing
|
||||||
|
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
||||||
|
- Rich link preview cards for YouTube, GitHub, Twitter/X, Reddit, Spotify, Twitch, Steam, Wikipedia, Discord, npm, Stack Overflow, and IMDb
|
||||||
|
|
||||||
|
### Calls & Voice
|
||||||
|
|
||||||
|
- Push to Talk with a configurable keybind (default: Space)
|
||||||
|
- Push to Deafen with the M key
|
||||||
|
- Camera starts turned off by default when joining a call
|
||||||
|
- Screenshare requires confirmation before going live
|
||||||
|
- Toggle noise suppression on or off
|
||||||
|
- Calls float in a draggable picture-in-picture window when you navigate away
|
||||||
|
- Your chat background shows through the call view
|
||||||
|
- Dark/light mode inside calls matches your Lotus Chat theme
|
||||||
|
- Calls are available in DMs and private groups only — no accidental mass rings
|
||||||
|
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
||||||
|
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
||||||
|
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
||||||
|
- Soundboard: upload your own short audio clips (like custom emojis — they sync across your devices) and play them into a call so everyone hears them
|
||||||
|
- Call quality settings: cap your microphone bitrate, screenshare bitrate, and screenshare framerate — handy on a slow connection (Settings → Calls)
|
||||||
|
- Room call permissions: admins can turn off screen sharing or make a room audio-only (no cameras) — enforced server-side for every Matrix client, and it stops an in-progress share within seconds of being switched off
|
||||||
|
|
||||||
|
### Customization & Appearance
|
||||||
|
|
||||||
|
- LotusGuild Terminal Design System (TDS) — a CRT terminal-inspired dark theme
|
||||||
|
- TDS light mode variant for daytime use
|
||||||
|
- 20+ static chat background patterns
|
||||||
|
- 5 animated chat backgrounds: Digital Rain, Star Drift, Grid Pulse, Aurora Flow, Fireflies (with improved per-layer looping, phosphor-flicker rain, fluid aurora sweep, and organic firefly bioluminescence)
|
||||||
|
- 11 seasonal & holiday theme overlays — Halloween, Christmas, New Year, Autumn, Valentine's Day, St. Patrick's Day, Earth Day, Lunar New Year, April Fools', Deep Space, and Retro Arcade; auto-selected by date with a manual override in Settings → Appearance
|
||||||
|
- Avatar decorations — 99 animated APNG overlays (Gaming, Cyber, Space, Fantasy, Nature, Spooky, Cozy, and more) that frame your avatar across the timeline, members list, and @mention autocomplete; visible to all Lotus Chat users; select in Settings → Account → Avatar Decoration
|
||||||
|
- Toggle to pause background animations
|
||||||
|
- Glassmorphism sidebar — frosted glass effect that lets the background show through
|
||||||
|
- Night Light / blue light filter with an adjustable intensity slider
|
||||||
|
- Emoji prefixes on room names render larger in the sidebar (e.g. 🎮 general)
|
||||||
|
- Rename any room for yourself only — other members see the original name
|
||||||
|
- Emoji picker on all room name inputs
|
||||||
|
|
||||||
|
### Presence & Profile
|
||||||
|
|
||||||
|
- Discord-style presence selector: Online, Idle, Do Not Disturb, Invisible, or Auto
|
||||||
|
- Custom status message with emoji and an optional auto-clear timer (changing your status is never silently overwritten by activity events)
|
||||||
|
- Colored presence ring on member avatars (green / yellow / red)
|
||||||
|
- Profile fields for pronouns and timezone
|
||||||
|
- When a user's timezone is set, their current local time appears in their profile
|
||||||
|
- Private notes on any user's profile — freeform text visible only to you, auto-saves and syncs across devices
|
||||||
|
- Unread count shown in the browser tab title
|
||||||
|
|
||||||
|
### Moderation & Privacy
|
||||||
|
|
||||||
|
- Report any room to homeserver admins from the room menu
|
||||||
|
- View policy lists and ban lists (Draupnir-compatible, read-only)
|
||||||
|
- Toggle private read receipts so others can't see when you've read messages
|
||||||
|
- Optional warning when an encrypted room contains unverified devices
|
||||||
|
- Full push rule editor in notification settings
|
||||||
|
- View and edit Server ACL rules in room settings
|
||||||
|
- Filterable room activity / mod log (joins, kicks, bans, power level changes, etc.)
|
||||||
|
- Room stats and insights panel (active members, top reactions, media breakdown, activity heatmap)
|
||||||
|
- Export room history as plain text, JSON, or HTML with optional date range filter
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
- In-app toast notifications appear bottom-right when the window is focused
|
||||||
|
- Custom notification sounds per category (messages, invites)
|
||||||
|
- Quiet hours — suppress notifications during a configured time window
|
||||||
|
- Click a toast to jump directly to the room or DM
|
||||||
|
|
||||||
|
### UX
|
||||||
|
|
||||||
|
- Filter and search rooms in the sidebar
|
||||||
|
- Favorite rooms sync across devices and appear in a pinned section
|
||||||
|
- Sort rooms by recent activity, alphabetical, or unread first
|
||||||
|
- DM rows show a message preview and relative timestamp
|
||||||
|
- Right-click a room for a context menu: mute with duration, copy link, mark as read
|
||||||
|
- Quick emoji reactions appear on message hover — one click to react
|
||||||
|
- Knock-to-join: request access to a room; admins approve or deny from the members list
|
||||||
|
- Media gallery drawer: browse all images, videos, and files shared in a room
|
||||||
|
- Invite link and QR code in room settings
|
||||||
|
- Pending knock requests shown in the members list for room admins with a live badge count on the Members button
|
||||||
|
- Homeserver support contact displayed in Help & About (MSC1929)
|
||||||
|
- Server notice rooms are visually distinct from regular DMs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop App
|
||||||
|
|
||||||
|
Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same web client in a native window with automatic background updates — no need to reinstall for new versions.
|
||||||
|
|
||||||
|
### Download
|
||||||
|
|
||||||
|
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
|
||||||
|
|
||||||
|
### SmartScreen Warning (Windows)
|
||||||
|
|
||||||
|
When you first run the installer on Windows, you may see a popup that says **"Windows protected your PC"** with the app listed as an unknown publisher. This is normal.
|
||||||
|
|
||||||
|
**Why it happens:** Windows SmartScreen flags any app that does not have an expensive commercial code-signing certificate from a major CA. Lotus Chat is signed with its own key for update verification, but that key is not in Microsoft's pre-approved list.
|
||||||
|
|
||||||
|
**How to install anyway:**
|
||||||
|
|
||||||
|
1. Click **"More info"** in the SmartScreen dialog.
|
||||||
|
2. A **"Run anyway"** button will appear.
|
||||||
|
3. Click it to proceed with installation.
|
||||||
|
|
||||||
|
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
||||||
|
|
||||||
|
### Desktop-Specific Features
|
||||||
|
|
||||||
|
Beyond the web client, the desktop app adds native OS integration (Windows-focused; graceful no-ops elsewhere). See [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#desktop-app-features) for detail.
|
||||||
|
|
||||||
|
- **Native rich notifications** — Windows toasts you can click to open the room or reply to inline, right from the toast.
|
||||||
|
- **Focus Assist sync** — Lotus silences its own notifications while Windows Focus Assist / Quiet Hours is on.
|
||||||
|
- **Windows Jump List** — right-click the taskbar icon for quick access to your most-active rooms.
|
||||||
|
- **Taskbar call controls** — Mute / Deafen / End Call buttons on the taskbar thumbnail during a call, plus call status in the volume flyout (SMTC).
|
||||||
|
- **Stays awake in calls** — the system won't sleep or dim during a voice/video call.
|
||||||
|
- **Network awareness** — reconnects promptly when Windows connectivity changes.
|
||||||
|
- **Custom window chrome** (opt-in) — a Lotus-styled title bar in place of the OS one.
|
||||||
|
- **Recursive folder drag-drop** — drop a whole folder onto the composer to upload everything inside it.
|
||||||
|
- **Automatic background updates** with a one-click update toast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Developers
|
||||||
|
|
||||||
|
The source code lives in `/root/code/cinny`. All changes should be made on the `lotus` branch. Push to `origin/lotus` and CI will automatically build and deploy to [chat.lotusguild.org](https://chat.lotusguild.org) in approximately 11 minutes — no manual build or deploy steps required.
|
||||||
|
|
||||||
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||||
|
|
||||||
|
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||||
|
|
||||||
|
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||||
|
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
|
||||||
|
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
||||||
|
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||||
|
behavior is editable source instead of fragile DOM/widget hacks.
|
||||||
|
|
||||||
|
**Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
|
||||||
|
reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
|
||||||
|
avatar decorations on EC video tiles, and a native transparent background.
|
||||||
|
**Built but dormant (need cinny UI):** real call-audio injection
|
||||||
|
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
|
||||||
|
(`io.lotus.set_quality`).
|
||||||
|
|
||||||
|
The full plan and integration map is in
|
||||||
|
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; infra/hosting +
|
||||||
|
build-pipeline notes live in the `LotusGuild/matrix` repo README. Search the docs
|
||||||
|
for the **`[EC-FORK]`** tag to find every related note.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci && npm run build # outputs to dist/
|
||||||
npm run build # outputs to dist/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Vite's render-chunks phase requires ~6 GB Node heap. If OOM killed, set:
|
If the build is killed due to out-of-memory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NODE_OPTIONS=--max_old_space_size=6144 npm run build
|
NODE_OPTIONS=--max_old_space_size=6144 npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development workflow
|
### CI/CD
|
||||||
|
|
||||||
All code changes should be made in the local clone at `/root/code/cinny` on the dev box, then committed and pushed to `origin/lotus`. The CI/CD pipeline handles everything from there — no manual build or deploy steps needed.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
edit → commit → git push # ~11 minutes → auto-deployed to chat.lotusguild.org
|
edit → commit → git push → ~11 min → live at chat.lotusguild.org
|
||||||
```
|
```
|
||||||
|
|
||||||
Pipeline (`.gitea/workflows/ci.yml` + `lotus_deploy.sh` on LXC 106):
|
|
||||||
1. Push triggers a Gitea Actions build — TypeScript check, ESLint, Prettier, bundle size report
|
|
||||||
2. Build must pass as the CI gate; quality checks are informational (`continue-on-error`)
|
|
||||||
3. A Gitea webhook fires `lotus_deploy.sh` on LXC 106, which polls the API until CI passes (up to 15 min), then pulls `origin/lotus`, runs `npm ci && npm run build`, and rsyncs to `/var/www/html/`
|
|
||||||
|
|
||||||
LXC 106's stored Gitea credential is **read-only** — it can only pull. Pushes must be done from the dev box with your personal credentials (entered manually, never cached).
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
Built files are served from `/var/www/html/` on LXC 106 (nginx). Config lives at `/opt/lotus-cinny/config.json` (vite copies it to `dist/`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"defaultHomeserver": 0,
|
|
||||||
"homeserverList": ["matrix.lotusguild.org"],
|
|
||||||
"allowCustomHomeservers": false,
|
|
||||||
"gifApiKey": "<giphy_key>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Custom Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/lotus-terminal.css.ts` | All TDS CSS tokens, global styles, light/dark variants |
|
|
||||||
| `src/lotus-boot.ts` | Boot sequence animation (runs once per session) |
|
|
||||||
| `src/app/hooks/useRoomReadPositions.ts` | Per-message read receipt position map |
|
|
||||||
| `src/app/features/room/ReadPositionsContext.ts` | React context for read positions |
|
|
||||||
| `src/app/components/read-receipt-avatars/` | Read receipt avatar pill component |
|
|
||||||
| `src/app/components/event-readers/EventReaders.tsx` | "Seen by" modal with timestamps |
|
|
||||||
| `src/app/components/GifPicker.tsx` | GIF search + send |
|
|
||||||
| `src/app/features/call/CallControls.tsx` | PTT badge + keybind logic |
|
|
||||||
| `src/app/plugins/call/CallControl.ts` | EC widget bridge (screenshare revert, PTT mic) |
|
|
||||||
| `src/app/components/CallEmbedProvider.tsx` | PiP + draggable call embed, call wallpaper carry-over |
|
|
||||||
| `src/app/plugins/call/CallEmbed.ts` | EC widget bridge: iframe setup, `color-scheme` dark/light injection, built-in control hiding, theme sync |
|
|
||||||
| `src/app/plugins/millify.ts` | Named import fix for Rolldown CJS interop (prevents `zc.default is not a function` crash) |
|
|
||||||
| `src/app/features/room/MediaGallery.tsx` | Right-side media gallery drawer (images/videos/files) |
|
|
||||||
| `src/app/features/room/PollCreator.tsx` | Poll creation modal (single/multiple choice, 2–10 options) |
|
|
||||||
| `src/app/features/common-settings/general/RoomShareInvite.tsx` | Invite link + QR code tile for room settings |
|
|
||||||
| `src/app/utils/syntaxHighlight.ts` | TDS code syntax tokenizer (JS/TS/Python/Rust → inline CSS vars) |
|
|
||||||
| `src/app/features/room-settings/ExportRoomHistory.tsx` | Export room messages to plain text / JSON / HTML with date range filter and E2EE awareness |
|
|
||||||
| `src/app/features/room-settings/RoomActivityLog.tsx` | Filterable mod log of room state events (joins, kicks, bans, power level changes, etc.) |
|
|
||||||
| `src/app/features/room-settings/RoomServerACL.tsx` | Server ACL viewer/editor (allow/deny lists, IP literal toggle, power-level gated) |
|
|
||||||
| `src/app/features/room-settings/RoomInsights.tsx` | Room stats panel: top members bar chart, top reactions, media breakdown, 24h heatmap |
|
|
||||||
| `src/app/features/bookmarks/BookmarksPanel.tsx` | Saved messages sidebar panel with filter, jump-to-message, and remove |
|
|
||||||
| `src/app/hooks/useBookmarks.ts` | Read/write `io.lotus.bookmarks` account data for message bookmarks |
|
|
||||||
| `src/app/features/room/ScheduleMessageModal.tsx` | Schedule-message modal with datetime picker; sends via MSC4140 delayed events API |
|
|
||||||
| `src/app/utils/scheduledMessages.ts` | Utilities for MSC4140 scheduled message state and cancel endpoint |
|
|
||||||
| `src/app/hooks/useExtendedProfile.ts` | Read/write MSC4133 extended profile fields (`m.pronouns`, `m.tz`) |
|
|
||||||
| `src/app/hooks/useLocalTime.ts` | Formats user local time from `m.tz` IANA zone; updates every 60 s |
|
|
||||||
| `src/app/components/url-preview/UrlPreviewCard.tsx` | Domain-specific URL preview cards for 13 sites + generic favicon fallback |
|
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
/*
|
||||||
|
* Lotus Chat — client-side ML noise suppression shim for Element Call.
|
||||||
|
*
|
||||||
|
* Element Call runs as a same-origin iframe widget that captures the mic
|
||||||
|
* internally (via livekit-client -> getUserMedia) and publishes it to LiveKit.
|
||||||
|
* We can't reach that track from the host. Instead this classic <script> is
|
||||||
|
* injected (by the vite `lotus-denoise` plugin) into EC's index.html BEFORE its
|
||||||
|
* deferred module entry, so it runs first and monkeypatches getUserMedia. When
|
||||||
|
* the "ml" tier is selected (lotusDenoise=ml in the widget URL) we route the
|
||||||
|
* captured mic through an RNNoise AudioWorklet (@sapphi-red/web-noise-suppressor)
|
||||||
|
* and hand the processed track back to EC/LiveKit.
|
||||||
|
*
|
||||||
|
* RNNoise REQUIRES mono, 48 kHz float audio. Feeding it anything else (stereo,
|
||||||
|
* or 44.1 kHz data the model treats as 48 kHz) produces loud static. So we:
|
||||||
|
* - run a 48 kHz AudioContext (which handles resampling from the hardware),
|
||||||
|
* - use the SIMD build if supported for better performance,
|
||||||
|
* - keep browser-native stationary suppression ON so the fans are removed
|
||||||
|
* before RNNoise focuses on transient noises (keyboard, dogs, etc.).
|
||||||
|
*
|
||||||
|
* Any failure falls back to the unprocessed mic so calls never break.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var params;
|
||||||
|
try {
|
||||||
|
params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('lotusDenoise') !== 'ml') return;
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
||||||
|
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
||||||
|
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
||||||
|
var targetOrigin;
|
||||||
|
try {
|
||||||
|
var parentUrl = params.get('parentUrl');
|
||||||
|
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
||||||
|
} catch (e) {
|
||||||
|
targetOrigin = window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
var md = navigator.mediaDevices;
|
||||||
|
if (!md || typeof md.getUserMedia !== 'function') return;
|
||||||
|
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||||
|
|
||||||
|
var ASSET_BASE = './denoise/';
|
||||||
|
|
||||||
|
var MODEL = params.get('lotusModel') || 'rnnoise';
|
||||||
|
// DTLN (@workadventure) targets 16 kHz and does not resample internally, so
|
||||||
|
// its whole graph runs in a 16 kHz context; RNNoise/Speex (sapphi) and
|
||||||
|
// DeepFilterNet 3 are 48 kHz fullband. The processed MediaStreamTrack is
|
||||||
|
// published to LiveKit either way (WebRTC/Opus resamples as needed).
|
||||||
|
var SAMPLE_RATE = MODEL === 'dtln' ? 16000 : 48000;
|
||||||
|
var USE_NATIVE_NS = params.get('lotusNativeNS') === 'true';
|
||||||
|
var USE_GATE = params.get('lotusGate') === 'true';
|
||||||
|
var GATE_THRESHOLD = parseFloat(params.get('lotusGateThreshold') || '-45');
|
||||||
|
|
||||||
|
var PROCESSORS = {
|
||||||
|
rnnoise: {
|
||||||
|
name: '@sapphi-red/web-noise-suppressor/rnnoise',
|
||||||
|
script: 'rnnoiseWorklet.js',
|
||||||
|
wasm: 'rnnoise.wasm',
|
||||||
|
simdWasm: 'rnnoise_simd.wasm',
|
||||||
|
},
|
||||||
|
speex: {
|
||||||
|
name: '@sapphi-red/web-noise-suppressor/speex',
|
||||||
|
script: 'speexWorklet.js',
|
||||||
|
wasm: 'speex.wasm',
|
||||||
|
},
|
||||||
|
dtln: {
|
||||||
|
// @workadventure/noise-suppression is a self-contained ES module that
|
||||||
|
// resolves its own AudioWorklet processor + LiteRT WASM + TFLite models
|
||||||
|
// via import.meta.url. We dynamic-import this helper and let it build the
|
||||||
|
// node, rather than addModule-ing a flat worklet ourselves.
|
||||||
|
helper: 'workadventure/audio-worklet.js',
|
||||||
|
},
|
||||||
|
deepfilternet: {
|
||||||
|
// deepfilternet3-noise-filter ships an ESM whose AudioWorklet processor +
|
||||||
|
// wasm-bindgen glue are INLINED as a string (loaded via a Blob URL — no
|
||||||
|
// CDN for the worklet). The only assets it fetches are its single-threaded
|
||||||
|
// df_bg.wasm + ONNX model, which we vendor + self-host under
|
||||||
|
// deepfilternet/v2/... We dynamic-import the ESM, build a DeepFilterNet3Core
|
||||||
|
// pointed at the self-hosted base, and let it create the worklet node.
|
||||||
|
esm: 'deepfilternet/index.esm.js',
|
||||||
|
},
|
||||||
|
gate: {
|
||||||
|
name: '@sapphi-red/web-noise-suppressor/noise-gate',
|
||||||
|
script: 'noiseGateWorklet.js',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var origGetUserMedia = md.getUserMedia.bind(md);
|
||||||
|
var wasmPromises = {};
|
||||||
|
var ctxPromise = null;
|
||||||
|
|
||||||
|
function checkSimd() {
|
||||||
|
try {
|
||||||
|
return WebAssembly.validate(
|
||||||
|
new Uint8Array([
|
||||||
|
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0,
|
||||||
|
253, 15, 253, 98, 11,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
? Promise.resolve(true)
|
||||||
|
: Promise.resolve(false);
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWasm(modelId) {
|
||||||
|
if (wasmPromises[modelId]) return wasmPromises[modelId];
|
||||||
|
var p = PROCESSORS[modelId];
|
||||||
|
if (!p || !p.wasm) return Promise.resolve(null);
|
||||||
|
|
||||||
|
wasmPromises[modelId] = (modelId === 'rnnoise' ? checkSimd() : Promise.resolve(false)).then(
|
||||||
|
function (simd) {
|
||||||
|
var file = simd && p.simdWasm ? p.simdWasm : p.wasm;
|
||||||
|
return fetch(ASSET_BASE + file).then(function (r) {
|
||||||
|
if (!r.ok) {
|
||||||
|
if (simd && p.simdWasm)
|
||||||
|
return fetch(ASSET_BASE + p.wasm).then(function (r2) {
|
||||||
|
if (!r2.ok) throw new Error(modelId + ' wasm failed');
|
||||||
|
return r2.arrayBuffer();
|
||||||
|
});
|
||||||
|
throw new Error(modelId + ' wasm failed');
|
||||||
|
}
|
||||||
|
return r.arrayBuffer();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return wasmPromises[modelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContext() {
|
||||||
|
if (!ctxPromise) {
|
||||||
|
ctxPromise = (function () {
|
||||||
|
var ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||||
|
if (ctx.sampleRate !== SAMPLE_RATE) {
|
||||||
|
try {
|
||||||
|
ctx.close();
|
||||||
|
} catch (e) {}
|
||||||
|
return Promise.reject(new Error('SampleRate mismatch: ' + ctx.sampleRate));
|
||||||
|
}
|
||||||
|
// Load worklet modules. DTLN registers its own processor via the
|
||||||
|
// dynamic-imported helper (see buildMlNode), so it needs nothing here.
|
||||||
|
var scripts = [];
|
||||||
|
if (MODEL === 'rnnoise' || MODEL === 'speex') scripts.push(PROCESSORS[MODEL].script);
|
||||||
|
if (USE_GATE) scripts.push(PROCESSORS.gate.script);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
scripts.map(function (s) {
|
||||||
|
return ctx.audioWorklet.addModule(ASSET_BASE + s);
|
||||||
|
}),
|
||||||
|
).then(function () {
|
||||||
|
return ctx.state === 'suspended'
|
||||||
|
? ctx.resume().then(function () {
|
||||||
|
return ctx;
|
||||||
|
})
|
||||||
|
: ctx;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
ctxPromise.catch(function () {
|
||||||
|
ctxPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ctxPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasNotifiedActive = false;
|
||||||
|
|
||||||
|
// Build the ML denoise AudioWorkletNode. RNNoise/Speex are flat sapphi
|
||||||
|
// worklets we instantiate directly with the fetched WASM binary. DTLN comes
|
||||||
|
// from @workadventure's self-contained helper, which we dynamic-import; it
|
||||||
|
// resolves its own processor + LiteRT WASM + TFLite models internally and
|
||||||
|
// returns the node. Resolves to { node, ready, dispose }.
|
||||||
|
function buildMlNode(ctx, wasmBinary) {
|
||||||
|
if (MODEL === 'dtln') {
|
||||||
|
return import(ASSET_BASE + PROCESSORS.dtln.helper).then(function (mod) {
|
||||||
|
// bypassUntilReady: pass raw audio through until the model is loaded so
|
||||||
|
// the call never has a silent/missing track during init.
|
||||||
|
return mod.createNoiseSuppressionAudioWorklet(ctx, { bypassUntilReady: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (MODEL === 'deepfilternet') {
|
||||||
|
// Resolve an absolute self-hosted base so the package's cdnUrl override
|
||||||
|
// fetches our vendored df_bg.wasm + ONNX model (never the upstream CDN).
|
||||||
|
var dfnBase = new URL(ASSET_BASE + 'deepfilternet', window.location.href).href;
|
||||||
|
return import(ASSET_BASE + PROCESSORS.deepfilternet.esm).then(function (mod) {
|
||||||
|
var core = new mod.DeepFilterNet3Core({
|
||||||
|
sampleRate: SAMPLE_RATE,
|
||||||
|
noiseReductionLevel: 80,
|
||||||
|
assetConfig: { cdnUrl: dfnBase },
|
||||||
|
});
|
||||||
|
// initialize() fetches + compiles the wasm and loads the model on the
|
||||||
|
// main thread; the worklet node only exists once that resolves, so the
|
||||||
|
// graph is connected with a ready model (no half-initialised passthrough).
|
||||||
|
return core.initialize().then(function () {
|
||||||
|
return core.createAudioWorkletNode(ctx).then(function (node) {
|
||||||
|
return {
|
||||||
|
node: node,
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
dispose: function () {
|
||||||
|
try {
|
||||||
|
core.destroy();
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
||||||
|
channelCount: 1,
|
||||||
|
numberOfInputs: 1,
|
||||||
|
numberOfOutputs: 1,
|
||||||
|
processorOptions: { maxChannels: 1, wasmBinary: wasmBinary },
|
||||||
|
});
|
||||||
|
return Promise.resolve({
|
||||||
|
node: node,
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
dispose: function () {
|
||||||
|
try {
|
||||||
|
node.port.postMessage('destroy');
|
||||||
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function processStream(stream) {
|
||||||
|
var audioTracks = stream.getAudioTracks();
|
||||||
|
if (audioTracks.length === 0) return Promise.resolve(stream);
|
||||||
|
|
||||||
|
return Promise.all([loadWasm(MODEL), getContext()])
|
||||||
|
.then(function (res) {
|
||||||
|
var wasmBinary = res[0];
|
||||||
|
var ctx = res[1];
|
||||||
|
|
||||||
|
var source = ctx.createMediaStreamSource(stream);
|
||||||
|
var dest = ctx.createMediaStreamDestination();
|
||||||
|
var head = source;
|
||||||
|
|
||||||
|
// 1. Optional Noise Gate
|
||||||
|
if (USE_GATE) {
|
||||||
|
var gateNode = new AudioWorkletNode(ctx, PROCESSORS.gate.name, {
|
||||||
|
processorOptions: {
|
||||||
|
openThreshold: GATE_THRESHOLD,
|
||||||
|
closeThreshold: GATE_THRESHOLD - 5,
|
||||||
|
holdMs: 150,
|
||||||
|
maxChannels: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
head.connect(gateNode);
|
||||||
|
head = gateNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ML Processor
|
||||||
|
return buildMlNode(ctx, wasmBinary).then(function (ml) {
|
||||||
|
var mlNode = ml.node;
|
||||||
|
head.connect(mlNode);
|
||||||
|
mlNode.connect(dest);
|
||||||
|
|
||||||
|
// Surface async init failures (e.g. DTLN model load) without blocking
|
||||||
|
// the track handoff — audio flows via bypassUntilReady meanwhile.
|
||||||
|
if (ml.ready && typeof ml.ready.then === 'function') {
|
||||||
|
ml.ready.catch(function (err) {
|
||||||
|
var m = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[lotus-denoise] ' + MODEL + ' init failed:', m);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var origTrack = audioTracks[0];
|
||||||
|
var processedTrack = dest.stream.getAudioTracks()[0];
|
||||||
|
|
||||||
|
var torndown = false;
|
||||||
|
function cleanup() {
|
||||||
|
if (torndown) return;
|
||||||
|
torndown = true;
|
||||||
|
try {
|
||||||
|
ml.dispose();
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
source.disconnect();
|
||||||
|
mlNode.disconnect();
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
if (gateNode) gateNode.disconnect();
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
origTrack.stop();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawStop = processedTrack.stop.bind(processedTrack);
|
||||||
|
processedTrack.stop = function () {
|
||||||
|
cleanup();
|
||||||
|
rawStop();
|
||||||
|
};
|
||||||
|
origTrack.addEventListener('ended', function () {
|
||||||
|
try {
|
||||||
|
rawStop();
|
||||||
|
} catch (e) {}
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasNotifiedActive) {
|
||||||
|
hasNotifiedActive = true;
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
type: 'lotus-denoise-status',
|
||||||
|
active: true,
|
||||||
|
model: MODEL,
|
||||||
|
nativeNS: USE_NATIVE_NS,
|
||||||
|
gate: USE_GATE,
|
||||||
|
},
|
||||||
|
targetOrigin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var out = new MediaStream();
|
||||||
|
out.addTrack(processedTrack);
|
||||||
|
stream.getVideoTracks().forEach(function (t) {
|
||||||
|
out.addTrack(t);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
var msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error('[lotus-denoise] Setup failed:', msg);
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ type: 'lotus-denoise-status', active: false, error: msg },
|
||||||
|
targetOrigin,
|
||||||
|
);
|
||||||
|
return stream;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.mediaDevices.getUserMedia = function (constraints) {
|
||||||
|
var wantsAudio = !!(constraints && constraints.audio);
|
||||||
|
var effective = constraints;
|
||||||
|
if (wantsAudio) {
|
||||||
|
var audioC =
|
||||||
|
typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
|
||||||
|
audioC.noiseSuppression = USE_NATIVE_NS;
|
||||||
|
audioC.channelCount = 1;
|
||||||
|
if (audioC.echoCancellation === undefined) audioC.echoCancellation = true;
|
||||||
|
if (audioC.autoGainControl === undefined) audioC.autoGainControl = true;
|
||||||
|
effective = Object.assign({}, constraints, { audio: audioC });
|
||||||
|
}
|
||||||
|
return origGetUserMedia(effective).then(function (stream) {
|
||||||
|
return wantsAudio ? processStream(stream) : stream;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Local OIDC / next-gen-auth (MSC3861) test loop
|
||||||
|
|
||||||
|
The Lotus client gained MSC3861/MSC2965 OIDC login (P4-6). lotusguild's own
|
||||||
|
homeserver is **not** MSC3861, so to exercise the flow without a mozilla.org
|
||||||
|
tester you need a local homeserver that delegates auth to a **Matrix
|
||||||
|
Authentication Service (MAS)**. This is the dev loop.
|
||||||
|
|
||||||
|
> Status: the Lotus-client side is unit-tested + gate-green; this server loop is
|
||||||
|
> the manual end-to-end check. It hasn't been run in CI (no container runtime
|
||||||
|
> there), so treat version pins as a starting point and bump as needed.
|
||||||
|
|
||||||
|
## 1. Stand up MAS + Synapse
|
||||||
|
|
||||||
|
The simplest path is the **upstream MAS docker-compose quickstart** — it's
|
||||||
|
maintained and handles key generation + the database:
|
||||||
|
<https://element-hq.github.io/matrix-authentication-service/setup/installation.html>
|
||||||
|
(`docker compose` section). Use it to get MAS + Synapse + Postgres running, then
|
||||||
|
apply the two Lotus-specific deltas below.
|
||||||
|
|
||||||
|
A minimal `compose.yaml` skeleton (generate MAS keys first — do **not** hand-write them):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment: { POSTGRES_USER: synapse, POSTGRES_PASSWORD: pw, POSTGRES_DB: synapse }
|
||||||
|
mas:
|
||||||
|
image: ghcr.io/element-hq/matrix-authentication-service:latest
|
||||||
|
command: server
|
||||||
|
ports: ['8090:8080'] # MAS issuer on http://localhost:8090
|
||||||
|
volumes: ['./mas:/data']
|
||||||
|
# First run once: `docker compose run --rm mas config generate -o /data/config.yaml`
|
||||||
|
# then edit /data/mas/config.yaml (see §1a) before `up`.
|
||||||
|
synapse:
|
||||||
|
image: ghcr.io/element-hq/synapse:latest
|
||||||
|
ports: ['8008:8008'] # client/federation API
|
||||||
|
volumes: ['./synapse:/data']
|
||||||
|
depends_on: [postgres, mas]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1a. MAS `config.yaml` — the parts that matter
|
||||||
|
After `config generate` (which fills in `secrets.keys` + `encryption`), set:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
public_base: http://localhost:8090/
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
database:
|
||||||
|
uri: postgresql://synapse:pw@postgres/synapse
|
||||||
|
matrix:
|
||||||
|
homeserver: localhost # the server_name
|
||||||
|
endpoint: http://synapse:8008/
|
||||||
|
secret: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
||||||
|
clients:
|
||||||
|
- client_id: "0000000000000000000SYNAPSE"
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
||||||
|
passwords: # so you can create a local test account in the MAS UI
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1b. Synapse `homeserver.yaml` — delegate auth to MAS
|
||||||
|
See `synapse-msc3861.yaml` in this folder; the key block is:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
experimental_features:
|
||||||
|
msc3861:
|
||||||
|
enabled: true
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
client_id: "0000000000000000000SYNAPSE"
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" # == MAS clients[].client_secret
|
||||||
|
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" # == MAS matrix.secret
|
||||||
|
account_management_url: "http://localhost:8090/account"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a test user via the MAS UI (`http://localhost:8090/`) or
|
||||||
|
`docker compose exec mas mas-cli manage register-user`.
|
||||||
|
|
||||||
|
Sanity check discovery (the client relies on this):
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8008/.well-known/matrix/client | jq '."m.authentication"'
|
||||||
|
# -> { "issuer": "http://localhost:8090/", "account": "http://localhost:8090/account" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Point the Lotus dev client at it
|
||||||
|
|
||||||
|
Run the client: `npm start` (vite dev). Override `public/config.json` so the
|
||||||
|
local server is selectable and custom servers are allowed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"defaultHomeserver": 0,
|
||||||
|
"homeserverList": ["localhost:8008"],
|
||||||
|
"allowCustomHomeservers": true,
|
||||||
|
"hashRouter": { "enabled": false, "basename": "/" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dynamic client registration handles the redirect URI automatically — it's
|
||||||
|
`<vite-origin>/auth/oidc/callback` (e.g. `http://localhost:5173/auth/oidc/callback`),
|
||||||
|
and MAS allows `http://localhost` redirects in dev.
|
||||||
|
|
||||||
|
## 3. Run the checklist
|
||||||
|
|
||||||
|
See **section N** of `../../LOTUS_TESTING.md` for the actual pass/fail steps
|
||||||
|
(login redirect, callback, session-persist-on-reload, token refresh, logout
|
||||||
|
revocation, account-management link, and the non-OIDC-regression check).
|
||||||
|
|
||||||
|
## Files here
|
||||||
|
- `synapse-msc3861.yaml` — the Synapse experimental-features delta.
|
||||||
|
- `config.local.json` — the Lotus `public/config.json` override.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"defaultHomeserver": 0,
|
||||||
|
"homeserverList": ["localhost:8008"],
|
||||||
|
"allowCustomHomeservers": true,
|
||||||
|
"featuredCommunities": { "openAsDefault": false, "spaces": [], "rooms": [], "servers": [] },
|
||||||
|
"hashRouter": { "enabled": false, "basename": "/" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Synapse experimental-features delta to delegate auth to a local MAS (MSC3861).
|
||||||
|
# Merge this into your test homeserver.yaml. The client_secret + admin_token MUST
|
||||||
|
# match the MAS config (clients[].client_secret and matrix.secret respectively).
|
||||||
|
experimental_features:
|
||||||
|
msc3861:
|
||||||
|
enabled: true
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
client_id: "0000000000000000000SYNAPSE"
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
||||||
|
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
||||||
|
account_management_url: "http://localhost:8090/account"
|
||||||
|
|
||||||
|
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
|
||||||
|
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
|
||||||
|
# Lotus client's getOidcIssuer() reads to switch into the OIDC flow.
|
||||||
@@ -29,10 +29,8 @@
|
|||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
|
||||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap"
|
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|||||||
|
After Width: | Height: | Size: 851 KiB |
|
After Width: | Height: | Size: 944 KiB |
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lotus-chat",
|
"name": "lotus-chat",
|
||||||
"version": "4.12.2-lotus",
|
"version": "4.12.3-lotus",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lotus-chat",
|
"name": "lotus-chat",
|
||||||
"version": "4.12.2-lotus",
|
"version": "4.12.3-lotus",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -20,11 +20,12 @@
|
|||||||
"@giphy/js-types": "5.1.0",
|
"@giphy/js-types": "5.1.0",
|
||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "5.2.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sentry/react": "10.53.1",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
"@types/dompurify": "3.2.0",
|
"@types/dompurify": "3.2.0",
|
||||||
|
"@workadventure/noise-suppression": "0.0.4",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
"badwords-list": "2.0.1-4",
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"dateformat": "5.0.3",
|
"dateformat": "5.0.3",
|
||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
|
"deepfilternet3-noise-filter": "1.2.1",
|
||||||
"domhandler": "6.0.1",
|
"domhandler": "6.0.1",
|
||||||
"dompurify": "3.4.5",
|
"dompurify": "3.4.5",
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
@@ -51,7 +53,6 @@
|
|||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"lodash": "4.18.1",
|
|
||||||
"matrix-js-sdk": "41.6.0-rc.0",
|
"matrix-js-sdk": "41.6.0-rc.0",
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
@@ -76,10 +77,9 @@
|
|||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@element-hq/element-call-embedded": "0.19.4",
|
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@sentry/vite-plugin": "5.3.0",
|
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
@@ -1788,12 +1789,6 @@
|
|||||||
"node": ">=v18"
|
"node": ">=v18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@element-hq/element-call-embedded": {
|
|
||||||
"version": "0.19.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.19.4.tgz",
|
|
||||||
"integrity": "sha512-crawgHughTv6yYoCqgq7cKLxUDtYU/Xr7KgSFCT0NM++QHoYsM5WGmIU/yY2Q0QYPuzmHXIEK95IZmJaQ1jIJA==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -2693,6 +2688,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
|
"version": "0.20.1-lotus.1",
|
||||||
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.20.1-lotus.1/element-call-embedded-0.20.1-lotus.1.tgz",
|
||||||
|
"integrity": "sha512-hy1KEnFw4MuwvlactUFPPvvtPZh1y56JMK/ehnficUmJNwdJsOhSwThaYp35RZ/ar6RCuiW86yQqlQBOSpZJVQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "18.3.0",
|
"version": "18.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
||||||
@@ -3774,402 +3775,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/browser-utils": {
|
"node_modules/@sapphi-red/web-noise-suppressor": {
|
||||||
"version": "10.53.1",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sapphi-red/web-noise-suppressor/-/web-noise-suppressor-0.3.5.tgz",
|
||||||
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
|
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/feedback": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay-canvas": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/replay": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/babel-plugin-component-annotate": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/browser": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.53.1",
|
|
||||||
"@sentry-internal/feedback": "10.53.1",
|
|
||||||
"@sentry-internal/replay": "10.53.1",
|
|
||||||
"@sentry-internal/replay-canvas": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/core": "^7.18.5",
|
|
||||||
"@sentry/babel-plugin-component-annotate": "5.3.0",
|
|
||||||
"@sentry/cli": "^2.58.5",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"find-up": "^5.0.0",
|
|
||||||
"glob": "^13.0.6",
|
|
||||||
"magic-string": "~0.30.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/glob": {
|
|
||||||
"version": "13.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
|
||||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"minimatch": "^10.2.2",
|
|
||||||
"minipass": "^7.1.3",
|
|
||||||
"path-scurry": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": {
|
|
||||||
"version": "10.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
|
||||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^5.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minipass": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"https-proxy-agent": "^5.0.0",
|
|
||||||
"node-fetch": "^2.6.7",
|
|
||||||
"progress": "^2.0.3",
|
|
||||||
"proxy-from-env": "^1.1.0",
|
|
||||||
"which": "^2.0.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"sentry-cli": "bin/sentry-cli"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@sentry/cli-darwin": "2.58.6",
|
|
||||||
"@sentry/cli-linux-arm": "2.58.6",
|
|
||||||
"@sentry/cli-linux-arm64": "2.58.6",
|
|
||||||
"@sentry/cli-linux-i686": "2.58.6",
|
|
||||||
"@sentry/cli-linux-x64": "2.58.6",
|
|
||||||
"@sentry/cli-win32-arm64": "2.58.6",
|
|
||||||
"@sentry/cli-win32-i686": "2.58.6",
|
|
||||||
"@sentry/cli-win32-x64": "2.58.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-darwin": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-arm": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-arm64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-i686": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==",
|
|
||||||
"cpu": [
|
|
||||||
"x86",
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-x64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-arm64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-i686": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==",
|
|
||||||
"cpu": [
|
|
||||||
"x86",
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-x64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/core": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/react": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/browser": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/rollup-plugin": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/bundler-plugin-core": "5.3.0",
|
|
||||||
"magic-string": "~0.30.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"rollup": ">=3.2.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"rollup": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/vite-plugin": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/bundler-plugin-core": "5.3.0",
|
|
||||||
"@sentry/rollup-plugin": "5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@simple-libs/stream-utils": {
|
"node_modules/@simple-libs/stream-utils": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
@@ -4849,6 +4459,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@workadventure/noise-suppression": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@workadventure/noise-suppression/-/noise-suppression-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-v8DQgV2TQAWh7YLo7bZ1grV3iDNltRuvPaIYTcaBWoOjUaxDp/j5zrFLz4ZuijPGxzqcQxeW7ql/HJltMuLDtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fft.js": "^4.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xobotyi/scrollbar-width": {
|
"node_modules/@xobotyi/scrollbar-width": {
|
||||||
"version": "1.9.5",
|
"version": "1.9.5",
|
||||||
"resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
|
||||||
@@ -4876,18 +4495,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -6382,6 +5989,18 @@
|
|||||||
"integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==",
|
"integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/deepfilternet3-noise-filter": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepfilternet3-noise-filter/-/deepfilternet3-noise-filter-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-OAyrHTDlUHH+AhfpVNKYEOhVqb9cZpu0fdNThplA/tB/Ts4PF/UsI+abl2n1IbSxUkhiF0OqDejEhk1n42Oqpw==",
|
||||||
|
"license": "(Apache-2.0 OR MIT)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"livekit-client": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -6605,19 +6224,6 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
|
||||||
"version": "16.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
|
||||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -7612,6 +7218,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fft.js": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -8438,19 +8050,6 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "6",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/husky": {
|
"node_modules/husky": {
|
||||||
"version": "9.1.7",
|
"version": "9.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
@@ -10564,26 +10163,6 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-url": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "4.x || >=6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"encoding": "^0.1.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"encoding": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@@ -11143,16 +10722,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/progress": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -11163,13 +10732,6 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -12748,12 +12310,6 @@
|
|||||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
|
||||||
"version": "0.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -12802,6 +12358,25 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -13301,22 +12876,6 @@
|
|||||||
"defaults": "^1.0.3"
|
"defaults": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-url": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "~0.0.3",
|
|
||||||
"webidl-conversions": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lotus-chat",
|
"name": "lotus-chat",
|
||||||
"version": "4.12.2-lotus",
|
"version": "4.12.3-lotus",
|
||||||
"description": "Lotus Chat — Matrix client for Lotus Guild",
|
"description": "Lotus Chat — Matrix client for Lotus Guild",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -16,9 +16,11 @@
|
|||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"commit": "git-cz",
|
"commit": "git-cz",
|
||||||
"postinstall": "node scripts/patch-folds.mjs"
|
"postinstall": "node scripts/patch-folds.mjs",
|
||||||
|
"sync:decorations": "node scripts/syncDecorations.mjs"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,jsx}": "eslint",
|
"*.{ts,tsx,js,jsx}": "eslint",
|
||||||
@@ -43,11 +45,12 @@
|
|||||||
"@giphy/js-types": "5.1.0",
|
"@giphy/js-types": "5.1.0",
|
||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "5.2.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sentry/react": "10.53.1",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
"@types/dompurify": "3.2.0",
|
"@types/dompurify": "3.2.0",
|
||||||
|
"@workadventure/noise-suppression": "0.0.4",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
"badwords-list": "2.0.1-4",
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
@@ -56,6 +59,7 @@
|
|||||||
"classnames": "2.5.1",
|
"classnames": "2.5.1",
|
||||||
"dateformat": "5.0.3",
|
"dateformat": "5.0.3",
|
||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
|
"deepfilternet3-noise-filter": "1.2.1",
|
||||||
"domhandler": "6.0.1",
|
"domhandler": "6.0.1",
|
||||||
"dompurify": "3.4.5",
|
"dompurify": "3.4.5",
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
@@ -74,7 +78,6 @@
|
|||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"lodash": "4.18.1",
|
|
||||||
"matrix-js-sdk": "41.6.0-rc.0",
|
"matrix-js-sdk": "41.6.0-rc.0",
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
@@ -99,10 +102,9 @@
|
|||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@element-hq/element-call-embedded": "0.19.4",
|
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@sentry/vite-plugin": "5.3.0",
|
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
@@ -131,6 +133,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<title>Error 404 (Not Found)!!1</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||||
|
</style>
|
||||||
|
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||||
|
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||||
|
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<title>Error 404 (Not Found)!!1</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||||
|
</style>
|
||||||
|
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||||
|
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||||
|
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<title>Error 404 (Not Found)!!1</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||||
|
</style>
|
||||||
|
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||||
|
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||||
|
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* Self-hosted fonts — avoids tracking prevention in desktop WebView2 */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/JetBrainsMono-italic-400.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/JetBrainsMono-normal-400.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/JetBrainsMono-normal-700.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fira Code';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/FiraCode-400.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fira Code';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/FiraCode-600.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
@@ -2,6 +2,57 @@
|
|||||||
"Organisms": {
|
"Organisms": {
|
||||||
"RoomCommon": {
|
"RoomCommon": {
|
||||||
"changed_room_name": " changed room name"
|
"changed_room_name": " changed room name"
|
||||||
|
},
|
||||||
|
"CreateRoom": {
|
||||||
|
"chat_room": "Chat Room",
|
||||||
|
"chat_room_desc": "Messages, photos, and videos.",
|
||||||
|
"voice_room": "Voice Room",
|
||||||
|
"voice_room_desc": "Live audio and video conversations."
|
||||||
|
},
|
||||||
|
"ImageViewer": {
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
|
"Message": {
|
||||||
|
"open_location": "Open Location",
|
||||||
|
"thread": "Thread"
|
||||||
|
},
|
||||||
|
"ImageContent": {
|
||||||
|
"view": "View",
|
||||||
|
"spoiler": "Spoiler",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"DeviceVerification": {
|
||||||
|
"close": "Close",
|
||||||
|
"accept": "Accept",
|
||||||
|
"they_match": "They Match",
|
||||||
|
"okay": "Okay",
|
||||||
|
"do_not_match": "Do not Match",
|
||||||
|
"please_accept": "Please accept the request from other device.",
|
||||||
|
"waiting_accept": "Waiting for request to be accepted...",
|
||||||
|
"click_accept": "Click accept to start the verification process.",
|
||||||
|
"request_accepted": "Verification request has been accepted.",
|
||||||
|
"waiting_response": "Waiting for the response from other device...",
|
||||||
|
"starting_emoji": "Starting verification using emoji comparison...",
|
||||||
|
"confirm_emoji": "Confirm the emoji below are displayed on both devices, in the same order:",
|
||||||
|
"device_verified": "Your device is verified.",
|
||||||
|
"verification_canceled": "Verification has been canceled."
|
||||||
|
},
|
||||||
|
"UrlPreview": {
|
||||||
|
"join_server": "Join Server"
|
||||||
|
},
|
||||||
|
"InviteUser": {
|
||||||
|
"invite": "Invite"
|
||||||
|
},
|
||||||
|
"UploadBoard": {
|
||||||
|
"files": "Files",
|
||||||
|
"send": "Send",
|
||||||
|
"upload_failed": "Upload Failed"
|
||||||
|
},
|
||||||
|
"PasswordStage": {
|
||||||
|
"account_password": "Account Password",
|
||||||
|
"password": "Password",
|
||||||
|
"invalid_password": "Invalid Password!",
|
||||||
|
"authenticate_prompt": "To perform this action you need to authenticate yourself by entering you account password."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,49 +11,61 @@
|
|||||||
"theme_color": "#980000",
|
"theme_color": "#980000",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-36x36.png",
|
"src": "./res/android/android-chrome-36x36.png",
|
||||||
"sizes": "36x36",
|
"sizes": "36x36",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-48x48.png",
|
"src": "./res/android/android-chrome-48x48.png",
|
||||||
"sizes": "48x48",
|
"sizes": "48x48",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-72x72.png",
|
"src": "./res/android/android-chrome-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-96x96.png",
|
"src": "./res/android/android-chrome-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-144x144.png",
|
"src": "./res/android/android-chrome-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-192x192.png",
|
"src": "./res/android/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-256x256.png",
|
"src": "./res/android/android-chrome-256x256.png",
|
||||||
"sizes": "256x256",
|
"sizes": "256x256",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-384x384.png",
|
"src": "./res/android/android-chrome-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-512x512.png",
|
"src": "./res/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./res/android/maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./res/android/maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["social", "communication", "productivity"],
|
"categories": ["social", "communication", "productivity"],
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -19,8 +19,17 @@ try {
|
|||||||
writeFileSync(foldsPath, content, 'utf8');
|
writeFileSync(foldsPath, content, 'utf8');
|
||||||
console.log('Applied defensive Icon src guard to folds.');
|
console.log('Applied defensive Icon src guard to folds.');
|
||||||
} else {
|
} else {
|
||||||
console.warn('Warning: folds Icon patch target not found - may need updating.');
|
// Genuine "patch could not be applied" case: the target string is gone
|
||||||
|
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
|
||||||
|
// so the postinstall hook / CI breaks loudly instead of silently shipping
|
||||||
|
// an unpatched folds (which crashes at render with "src is not a function").
|
||||||
|
console.error(
|
||||||
|
'ERROR: folds Icon patch target not found - folds may have updated. ' +
|
||||||
|
'Update the patch target string in scripts/patch-folds.mjs before building.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Warning: Could not patch folds:', e.message);
|
console.error('ERROR: Could not patch folds:', e.message);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Syncs avatarDecorations.ts with what's actually available on the Nextcloud CDN.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run sync:decorations
|
||||||
|
*
|
||||||
|
* Workflow after deleting files from Nextcloud:
|
||||||
|
* 1. Delete decoration files from your Nextcloud share.
|
||||||
|
* 2. Run: npm run sync:decorations
|
||||||
|
* 3. It probes each catalog slug via HTTP HEAD and removes entries
|
||||||
|
* whose files returned 404. Empty categories are dropped automatically.
|
||||||
|
* 4. Commit the updated avatarDecorations.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = join(__dirname, '..');
|
||||||
|
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
||||||
|
|
||||||
|
// Single source of truth: the CDN base URL lives in avatarDecorations.ts as
|
||||||
|
// `export const DECORATION_CDN`. We extract it from there at runtime rather than
|
||||||
|
// re-declaring it here, so the build script and the app can never drift. This
|
||||||
|
// .mjs script can't cleanly import the browser-side .ts module (it's outside the
|
||||||
|
// Vite/TS app graph), so we parse the constant out of the file text instead.
|
||||||
|
// If you migrate the CDN, change it ONLY in avatarDecorations.ts.
|
||||||
|
const catalog = readFileSync(catalogPath, 'utf8');
|
||||||
|
|
||||||
|
const cdnMatch = catalog.match(/export const DECORATION_CDN\s*=\s*['"]([^'"]+)['"]/);
|
||||||
|
if (!cdnMatch) {
|
||||||
|
console.error(
|
||||||
|
'Could not find `export const DECORATION_CDN` in avatarDecorations.ts — ' +
|
||||||
|
'the constant may have been renamed. Update scripts/syncDecorations.mjs.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const CDN = cdnMatch[1];
|
||||||
|
|
||||||
|
// Extract all slugs from the catalog file
|
||||||
|
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
|
||||||
|
|
||||||
|
if (slugMatches.length === 0) {
|
||||||
|
console.error('No slugs found in catalog — check the file path.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Checking ${slugMatches.length} decorations against ${CDN} …`);
|
||||||
|
console.log('(This makes one HEAD request per decoration)\n');
|
||||||
|
|
||||||
|
// Probe all slugs in parallel batches of 16
|
||||||
|
async function headCheck(slug) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
||||||
|
return { slug, ok: res.ok, status: res.status };
|
||||||
|
} catch {
|
||||||
|
// Network/DNS/TLS failure — NOT a confirmation the file is gone.
|
||||||
|
return { slug, ok: false, status: 0, networkError: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH = 16;
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < slugMatches.length; i += BATCH) {
|
||||||
|
const batch = slugMatches.slice(i, i + BATCH);
|
||||||
|
const batchResults = await Promise.all(batch.map(headCheck));
|
||||||
|
results.push(...batchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to
|
||||||
|
// remove. A network error or any other non-ok status (5xx, 403, timeout) is
|
||||||
|
// ambiguous — the CDN may be unreachable — so refuse to remove anything and
|
||||||
|
// abort, otherwise a transient outage would wipe the whole catalog from source
|
||||||
|
// control (N119).
|
||||||
|
const transient = results.filter((r) => !r.ok && r.status !== 404);
|
||||||
|
if (transient.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Aborting: ${transient.length} decoration(s) returned a non-404 failure ` +
|
||||||
|
`(network error / server error). The CDN may be unreachable — refusing to ` +
|
||||||
|
`remove entries to avoid wiping the catalog.`,
|
||||||
|
);
|
||||||
|
transient
|
||||||
|
.slice(0, 8)
|
||||||
|
.forEach((r) =>
|
||||||
|
console.error(` ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = results.filter((r) => r.status === 404);
|
||||||
|
const found = results.filter((r) => r.ok);
|
||||||
|
|
||||||
|
if (missing.length === 0) {
|
||||||
|
console.log(`All ${found.length} decorations are available — catalog is up to date.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found: ${found.length} Missing: ${missing.length}\n`);
|
||||||
|
missing.forEach((r) => console.log(` Removing (HTTP ${r.status}): ${r.slug}`));
|
||||||
|
|
||||||
|
const missingSet = new Set(missing.map((r) => r.slug));
|
||||||
|
|
||||||
|
// Remove individual entries for missing slugs
|
||||||
|
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) =>
|
||||||
|
missingSet.has(slug) ? '' : match,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop category blocks that now have an empty decorations array
|
||||||
|
updated = updated.replace(
|
||||||
|
/ \{\n id: '[^']+',\n label: '[^']+',\n decorations: \[\n?[ \t]*\],?\n \},?\n/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up stray blank lines
|
||||||
|
updated = updated.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
writeFileSync(catalogPath, updated, 'utf8');
|
||||||
|
console.log(
|
||||||
|
`\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`,
|
||||||
|
);
|
||||||
|
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
Dialog,
|
Dialog,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
@@ -36,12 +37,15 @@ import {
|
|||||||
useCallStart,
|
useCallStart,
|
||||||
} from '../hooks/useCallEmbed';
|
} from '../hooks/useCallEmbed';
|
||||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import CallSound from '../../../public/sound/call.ogg';
|
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
|
import { useCallQuality } from '../hooks/useCallQuality';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
@@ -50,10 +54,11 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
|
|||||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||||
import { getChatBg } from '../features/lotus/chatBackground';
|
import { getChatBg } from '../features/lotus/chatBackground';
|
||||||
|
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
||||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { getStateEvent, getMemberDisplayName } from '../utils/room';
|
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||||
@@ -61,6 +66,7 @@ import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
|||||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
|
import { zIndices } from '../styles/zIndex';
|
||||||
|
|
||||||
const PIP_MIN_W = 200;
|
const PIP_MIN_W = 200;
|
||||||
const PIP_MIN_H = 112;
|
const PIP_MIN_H = 112;
|
||||||
@@ -102,7 +108,8 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
const canAnswer = livekitSupported && rtcSupported;
|
const canAnswer = livekitSupported && rtcSupported;
|
||||||
const { room } = info;
|
const { room } = info;
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
const roomName = useRoomName(room);
|
const roomName = useRoomName(room);
|
||||||
const roomAvatar = useRoomAvatar(room, dm);
|
const roomAvatar = useRoomAvatar(room, dm);
|
||||||
@@ -123,23 +130,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const playSound = useCallback(() => {
|
|
||||||
const audioElement = audioRef.current;
|
|
||||||
audioElement?.play().catch(() => undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioEl = audioRef.current;
|
if (info.notificationType !== 'ring') return undefined;
|
||||||
if (info.notificationType === 'ring') {
|
const stop = startRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
playSound();
|
return stop;
|
||||||
}
|
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
||||||
return () => {
|
|
||||||
if (audioEl) {
|
|
||||||
audioEl.pause();
|
|
||||||
audioEl.currentTime = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [playSound, info.notificationType]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
@@ -152,112 +147,255 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
}, [info.senderTs, info.lifetime, onIgnore]);
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<OverlayCenter>
|
||||||
<OverlayCenter>
|
<FocusTrap
|
||||||
<FocusTrap
|
focusTrapOptions={{
|
||||||
focusTrapOptions={{
|
initialFocus: false,
|
||||||
initialFocus: false,
|
onDeactivate: () => onIgnore(),
|
||||||
onDeactivate: () => onIgnore(),
|
clickOutsideDeactivates: false,
|
||||||
clickOutsideDeactivates: false,
|
escapeDeactivates: false,
|
||||||
escapeDeactivates: false,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
<Text size="T200" align="Center">
|
||||||
<Text size="T200" align="Center">
|
{getMemberDisplayName(info.room, info.sender) ??
|
||||||
{getMemberDisplayName(info.room, info.sender) ??
|
getMxIdLocalPart(info.sender) ??
|
||||||
getMxIdLocalPart(info.sender) ??
|
info.sender}
|
||||||
info.sender}
|
</Text>
|
||||||
</Text>
|
<Box direction="Column" gap="500" alignItems="Center">
|
||||||
<Box direction="Column" gap="500" alignItems="Center">
|
<Box shrink="No">
|
||||||
<Box shrink="No">
|
<Avatar size="500" className={CallAvatarAnimation}>
|
||||||
<Avatar size="500" className={CallAvatarAnimation}>
|
<RoomAvatar
|
||||||
<RoomAvatar
|
roomId={room.roomId}
|
||||||
roomId={room.roomId}
|
src={avatarUrl}
|
||||||
src={avatarUrl}
|
alt={roomName}
|
||||||
alt={roomName}
|
renderFallback={() => (
|
||||||
renderFallback={() => (
|
<RoomIcon
|
||||||
<RoomIcon
|
roomType={room.getType()}
|
||||||
roomType={room.getType()}
|
size="400"
|
||||||
size="400"
|
joinRule={room.getJoinRule()}
|
||||||
joinRule={room.getJoinRule()}
|
filled
|
||||||
filled
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
</Avatar>
|
||||||
</Avatar>
|
|
||||||
</Box>
|
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
|
||||||
<Text size="H3" align="Center" truncate>
|
|
||||||
{roomName}
|
|
||||||
</Text>
|
|
||||||
<Text size="T300">
|
|
||||||
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
{!livekitSupported && (
|
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
||||||
<Text
|
<Text size="H3" align="Center" truncate>
|
||||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
{roomName}
|
||||||
size="L400"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
Your homeserver does not support calling.
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<Text size="T300" align="Center">
|
||||||
{!webRTCSupported() && (
|
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
||||||
<Text
|
|
||||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
|
||||||
size="L400"
|
|
||||||
align="Center"
|
|
||||||
>
|
|
||||||
Your browser does not support WebRTC, which is required for calling.
|
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
<Box direction="Column" gap="300">
|
|
||||||
<Button
|
|
||||||
style={{ flexGrow: 1 }}
|
|
||||||
variant="Success"
|
|
||||||
size="400"
|
|
||||||
radii="400"
|
|
||||||
onClick={() => onAnswer(room, info.intent === 'video')}
|
|
||||||
before={
|
|
||||||
<Icon
|
|
||||||
size="200"
|
|
||||||
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
|
||||||
filled
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
disabled={!canAnswer}
|
|
||||||
>
|
|
||||||
<Text as="span" size="B400">
|
|
||||||
Answer
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
style={{ flexGrow: 1 }}
|
|
||||||
variant={dm ? 'Critical' : 'Secondary'}
|
|
||||||
fill="Soft"
|
|
||||||
size="400"
|
|
||||||
radii="400"
|
|
||||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
|
||||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
|
||||||
>
|
|
||||||
<Text as="span" size="B400">
|
|
||||||
{dm ? 'Reject' : 'Ignore'}
|
|
||||||
</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Dialog>
|
{!livekitSupported && (
|
||||||
</FocusTrap>
|
<Text
|
||||||
</OverlayCenter>
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||||
</Overlay>
|
size="L400"
|
||||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
align="Center"
|
||||||
<source src={CallSound} type="audio/ogg" />
|
>
|
||||||
</audio>
|
Your homeserver does not support calling.
|
||||||
</>
|
</Text>
|
||||||
|
)}
|
||||||
|
{!webRTCSupported() && (
|
||||||
|
<Text
|
||||||
|
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||||
|
size="L400"
|
||||||
|
align="Center"
|
||||||
|
>
|
||||||
|
Your browser does not support WebRTC, which is required for calling.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant="Success"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => onAnswer(room, info.intent === 'video')}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="200"
|
||||||
|
src={info.intent === 'video' ? Icons.VideoCamera : Icons.Phone}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
disabled={!canAnswer}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Answer
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant={dm ? 'Critical' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
size="400"
|
||||||
|
radii="400"
|
||||||
|
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||||
|
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
{dm ? 'Reject' : 'Ignore'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingCallBannerProps = {
|
||||||
|
dm: boolean;
|
||||||
|
info: IncomingCallInfo;
|
||||||
|
onIgnore: () => void;
|
||||||
|
onAnswer: (room: Room, video: boolean) => void;
|
||||||
|
onReject: (room: Room, eventId: string) => void;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Compact, non-intrusive incoming-call notification shown when the user is
|
||||||
|
* ALREADY in a call. Unlike the full-screen `IncomingCall` overlay this is a
|
||||||
|
* corner banner that does not take over the screen, and it plays a single
|
||||||
|
* soft ping (via the one-shot ringtone preview) rather than the looping ring,
|
||||||
|
* so it doesn't talk over the active call.
|
||||||
|
*/
|
||||||
|
function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallBannerProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const { room } = info;
|
||||||
|
const isVideo = info.intent === 'video';
|
||||||
|
|
||||||
|
const [ringtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
|
const [ringtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
|
const roomName = useRoomName(room);
|
||||||
|
const roomAvatar = useRoomAvatar(room, dm);
|
||||||
|
const avatarUrl = roomAvatar
|
||||||
|
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const session = useCallSession(room);
|
||||||
|
useCallMembersChange(
|
||||||
|
session,
|
||||||
|
useCallback(
|
||||||
|
(members) => {
|
||||||
|
if (members.length === 0) {
|
||||||
|
onIgnore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onIgnore],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone
|
||||||
|
// + volume. We intentionally do NOT loop here — the user is mid-call — and we
|
||||||
|
// ping exactly once per incoming call, not again if the user happens to tweak
|
||||||
|
// ringtone settings while the banner is showing.
|
||||||
|
const pingedRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
if (info.notificationType !== 'ring') return;
|
||||||
|
if (pingedRef.current === info.refEventId) return;
|
||||||
|
pingedRef.current = info.refEventId;
|
||||||
|
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
|
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
onIgnore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setTimeout(onIgnore, remaining);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||||
|
|
||||||
|
const callerName =
|
||||||
|
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: config.space.S400,
|
||||||
|
right: config.space.S400,
|
||||||
|
zIndex: zIndices.inCallBanner,
|
||||||
|
width: toRem(300),
|
||||||
|
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
|
padding: config.space.S300,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: `0 8px 32px ${color.Other.Shadow}`,
|
||||||
|
}}
|
||||||
|
role="alert"
|
||||||
|
aria-label={`Incoming ${isVideo ? 'video' : 'voice'} call from ${roomName}`}
|
||||||
|
>
|
||||||
|
<Box gap="300" alignItems="Center">
|
||||||
|
<Box shrink="No">
|
||||||
|
<Avatar size="300" className={CallAvatarAnimation}>
|
||||||
|
<RoomAvatar
|
||||||
|
roomId={room.roomId}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={roomName}
|
||||||
|
renderFallback={() => (
|
||||||
|
<RoomIcon
|
||||||
|
roomType={room.getType()}
|
||||||
|
size="200"
|
||||||
|
joinRule={room.getJoinRule()}
|
||||||
|
filled
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100" style={{ minWidth: 0 }}>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{roomName}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300" truncate>
|
||||||
|
{isVideo ? 'Incoming video call' : 'Incoming voice call'}
|
||||||
|
{dm ? '' : ` · ${callerName}`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant="Success"
|
||||||
|
fill="Solid"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => onAnswer(room, isVideo)}
|
||||||
|
before={<Icon size="100" src={isVideo ? Icons.VideoCamera : Icons.Phone} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
Answer
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
variant={dm ? 'Critical' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
outlined
|
||||||
|
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||||
|
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
{dm ? 'Reject' : 'Ignore'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +407,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const directs = useAtomValue(mDirectAtom);
|
const directs = useAtomValue(mDirectAtom);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||||
@@ -279,6 +418,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
// only process rtc notification reference events.
|
// only process rtc notification reference events.
|
||||||
// we do not want to wait to decrypt all events.
|
// we do not want to wait to decrypt all events.
|
||||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||||
|
if (room?.isCallRoom()) return;
|
||||||
|
|
||||||
if (event.isEncrypted()) {
|
if (event.isEncrypted()) {
|
||||||
if (!event.isBeingDecrypted()) {
|
if (!event.isBeingDecrypted()) {
|
||||||
@@ -287,6 +427,31 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
await event.getDecryptionPromise();
|
await event.getDecryptionPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caller-side: a participant declined a call we're hosting in this room.
|
||||||
|
// Without this the caller's UI keeps "ringing" until the notification
|
||||||
|
// lifetime expires, with no indication the callee said no.
|
||||||
|
if (event.getType() === EventType.RTCDecline) {
|
||||||
|
const decliner = event.getSender();
|
||||||
|
if (
|
||||||
|
data.liveEvent &&
|
||||||
|
room &&
|
||||||
|
decliner &&
|
||||||
|
decliner !== mx.getSafeUserId() &&
|
||||||
|
callEmbed?.roomId === room.roomId
|
||||||
|
) {
|
||||||
|
const declinerName =
|
||||||
|
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
|
||||||
|
setToast({
|
||||||
|
id: `rtc-decline-${event.getId() ?? decliner}`,
|
||||||
|
displayName: declinerName,
|
||||||
|
body: 'Declined your call',
|
||||||
|
roomName: room.name,
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!room ||
|
!room ||
|
||||||
event.getType() !== EventType.RTCNotification ||
|
event.getType() !== EventType.RTCNotification ||
|
||||||
@@ -324,18 +489,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
);
|
);
|
||||||
if (!hasCallPermission) return;
|
if (!hasCallPermission) return;
|
||||||
|
|
||||||
// Only ring for DMs or private non-space group chats.
|
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||||
// Space voice channels and public rooms fire room-level RTC notifications
|
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||||
// whenever anyone joins — ringing every member is incorrect behaviour.
|
// restricted rooms, and public rooms must never trigger ringing.
|
||||||
|
if (room.isCallRoom()) return;
|
||||||
const isDirect = directs.has(room.roomId);
|
const isDirect = directs.has(room.roomId);
|
||||||
const isSpaceChild = !!getStateEvent(room, StateEvent.SpaceParent);
|
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||||
const joinRule = room.getJoinRule();
|
const joinRule = room.getJoinRule();
|
||||||
const isPrivateGroup =
|
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
||||||
!isSpaceChild &&
|
if (!isDirect && !isPrivateInviteGroup) return;
|
||||||
(joinRule === JoinRule.Invite ||
|
|
||||||
joinRule === JoinRule.Knock ||
|
|
||||||
joinRule === JoinRule.Restricted);
|
|
||||||
if (!isDirect && !isPrivateGroup) return;
|
|
||||||
|
|
||||||
const info: IncomingCallInfo = {
|
const info: IncomingCallInfo = {
|
||||||
room,
|
room,
|
||||||
@@ -352,7 +514,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
|
|
||||||
setCallInfo(info);
|
setCallInfo(info);
|
||||||
},
|
},
|
||||||
[mx, directs],
|
[mx, directs, callEmbed, setToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -388,10 +550,25 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
[startCall, navigateRoom],
|
[startCall, navigateRoom],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
|
if (!callInfo) return null;
|
||||||
|
// Already in this room's own call — no notification at all.
|
||||||
|
if (callEmbed?.roomId === callInfo.room.roomId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return !joined && callInfo ? (
|
// In a different call already: show the compact, non-intrusive banner
|
||||||
|
// instead of the full-screen takeover overlay.
|
||||||
|
if (joined) {
|
||||||
|
return (
|
||||||
|
<IncomingCallBanner
|
||||||
|
dm={dm}
|
||||||
|
info={callInfo}
|
||||||
|
onIgnore={handleIgnore}
|
||||||
|
onAnswer={handleAnswer}
|
||||||
|
onReject={handleReject}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<IncomingCall
|
<IncomingCall
|
||||||
dm={dm}
|
dm={dm}
|
||||||
info={callInfo}
|
info={callInfo}
|
||||||
@@ -399,14 +576,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
onAnswer={handleAnswer}
|
onAnswer={handleAnswer}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
/>
|
/>
|
||||||
) : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallUtils({ embed }: { embed: CallEmbed }) {
|
function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
useCallMemberSoundSync(embed);
|
useCallMemberSoundSync(embed);
|
||||||
|
useCallJoinLeaveSounds(embed);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
|
useCallQuality(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
embed,
|
embed,
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -417,34 +596,66 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shown inside the PiP window when the local microphone is muted. */
|
/**
|
||||||
|
* PiP status indicators:
|
||||||
|
* - Bottom-left badge: local mic muted (matches Discord/Slack convention — bottom-left = "your" mic)
|
||||||
|
* - Top-right badge: all remote participants are muted (quiet room warning)
|
||||||
|
*
|
||||||
|
* Deliberately separated so users never mistake remote-mute state for their own.
|
||||||
|
*/
|
||||||
function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
||||||
const allMuted = useRemoteAllMuted(callEmbed);
|
const mx = useMatrixClient();
|
||||||
if (!allMuted) return null;
|
const controlState = useCallControlState(callEmbed.control);
|
||||||
|
const allRemoteMuted = useRemoteAllMuted(callEmbed);
|
||||||
|
|
||||||
|
const localMicMuted = !controlState.microphone;
|
||||||
|
const localUserId = mx.getSafeUserId();
|
||||||
|
const localDisplayName = getMxIdLocalPart(localUserId) ?? localUserId;
|
||||||
|
|
||||||
|
// Dark translucent scrim is intentional: these badges overlay arbitrary
|
||||||
|
// video, so a theme surface token would not guarantee legibility.
|
||||||
|
const badgeStyle: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 3,
|
||||||
|
background: 'rgba(0,0,0,0.65)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
lineHeight: 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
aria-label="Microphone muted"
|
{localMicMuted && (
|
||||||
style={{
|
<div
|
||||||
position: 'absolute',
|
aria-label={`Your microphone is muted (${localDisplayName})`}
|
||||||
bottom: '8px',
|
title="Your microphone is muted"
|
||||||
left: '8px',
|
style={{ ...badgeStyle, bottom: config.space.S200, left: config.space.S200 }}
|
||||||
zIndex: 3,
|
>
|
||||||
background: 'rgba(0,0,0,0.60)',
|
<Icon size="100" src={Icons.MicMute} filled style={{ color: color.Critical.Main }} />
|
||||||
backdropFilter: 'blur(4px)',
|
<Text as="span" size="T200" style={{ color: color.Critical.Main }}>
|
||||||
borderRadius: '6px',
|
You
|
||||||
padding: '3px 7px',
|
</Text>
|
||||||
display: 'flex',
|
</div>
|
||||||
alignItems: 'center',
|
)}
|
||||||
gap: '4px',
|
{allRemoteMuted && (
|
||||||
pointerEvents: 'none',
|
<div
|
||||||
color: color.Critical.Main,
|
aria-label="All other participants are muted"
|
||||||
fontSize: '13px',
|
title="All other participants are muted"
|
||||||
lineHeight: 1,
|
style={{ ...badgeStyle, top: config.space.S200, right: config.space.S200 }}
|
||||||
userSelect: 'none',
|
>
|
||||||
}}
|
<Icon size="50" src={Icons.MicMute} style={{ color: color.Warning.Main }} />
|
||||||
>
|
<Text as="span" size="T200" style={{ color: color.Warning.Main }}>
|
||||||
<Icon size="100" src={Icons.MicMute} filled />
|
All muted
|
||||||
</div>
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,6 +711,21 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
[chatBackground, isDark],
|
[chatBackground, isDark],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePipFullscreen = useCallback(() => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
callEmbedRef.current?.requestFullscreen();
|
||||||
|
}
|
||||||
|
}, [callEmbedRef]);
|
||||||
|
|
||||||
const pipDragRef = React.useRef<{
|
const pipDragRef = React.useRef<{
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
@@ -526,7 +752,25 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
if (pipMode) {
|
if (pipMode) {
|
||||||
if (!wasInPip) {
|
if (!wasInPip) {
|
||||||
const saved = localStorage.getItem('pip-position');
|
const saved = localStorage.getItem('pip-position');
|
||||||
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
|
let savedPos: { left: number; top: number } | null = null;
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(saved) as { left?: unknown; top?: unknown };
|
||||||
|
// Validate shape + finiteness: a corrupt value would otherwise feed
|
||||||
|
// NaN into Math.min and produce an invalid `NaNpx` CSS value.
|
||||||
|
if (
|
||||||
|
raw &&
|
||||||
|
typeof raw.left === 'number' &&
|
||||||
|
Number.isFinite(raw.left) &&
|
||||||
|
typeof raw.top === 'number' &&
|
||||||
|
Number.isFinite(raw.top)
|
||||||
|
) {
|
||||||
|
savedPos = { left: raw.left, top: raw.top };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
savedPos = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
el.style.right = 'auto';
|
el.style.right = 'auto';
|
||||||
el.style.bottom = 'auto';
|
el.style.bottom = 'auto';
|
||||||
if (savedPos) {
|
if (savedPos) {
|
||||||
@@ -722,6 +966,54 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
document.addEventListener('touchend', onTouchEnd);
|
document.addEventListener('touchend', onTouchEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function applyResize(
|
||||||
|
el: HTMLElement,
|
||||||
|
corner: Corner,
|
||||||
|
sx: number,
|
||||||
|
sy: number,
|
||||||
|
sw: number,
|
||||||
|
sh: number,
|
||||||
|
sl: number,
|
||||||
|
st: number,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
) {
|
||||||
|
const dx = cx - sx;
|
||||||
|
const dy = cy - sy;
|
||||||
|
let w = sw;
|
||||||
|
let h = sh;
|
||||||
|
let l = sl;
|
||||||
|
let t = st;
|
||||||
|
if (corner === 'se') {
|
||||||
|
w = sw + dx;
|
||||||
|
h = sh + dy;
|
||||||
|
}
|
||||||
|
if (corner === 'sw') {
|
||||||
|
w = sw - dx;
|
||||||
|
h = sh + dy;
|
||||||
|
l = sl + sw - Math.max(PIP_MIN_W, w);
|
||||||
|
}
|
||||||
|
if (corner === 'ne') {
|
||||||
|
w = sw + dx;
|
||||||
|
h = sh - dy;
|
||||||
|
t = st + sh - Math.max(PIP_MIN_H, h);
|
||||||
|
}
|
||||||
|
if (corner === 'nw') {
|
||||||
|
w = sw - dx;
|
||||||
|
h = sh - dy;
|
||||||
|
l = sl + sw - Math.max(PIP_MIN_W, w);
|
||||||
|
t = st + sh - Math.max(PIP_MIN_H, h);
|
||||||
|
}
|
||||||
|
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
||||||
|
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
||||||
|
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
||||||
|
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
el.style.left = `${l}px`;
|
||||||
|
el.style.top = `${t}px`;
|
||||||
|
}
|
||||||
|
|
||||||
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -737,40 +1029,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
document.body.style.cursor = `${corner}-resize`;
|
document.body.style.cursor = `${corner}-resize`;
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
const onMove = (ev: MouseEvent) => {
|
const onMove = (ev: MouseEvent) => {
|
||||||
const dx = ev.clientX - sx;
|
applyResize(el, corner, sx, sy, sw, sh, sl, st, ev.clientX, ev.clientY);
|
||||||
const dy = ev.clientY - sy;
|
|
||||||
let w = sw;
|
|
||||||
let h = sh;
|
|
||||||
let l = sl;
|
|
||||||
let t = st;
|
|
||||||
if (corner === 'se') {
|
|
||||||
w = sw + dx;
|
|
||||||
h = sh + dy;
|
|
||||||
}
|
|
||||||
if (corner === 'sw') {
|
|
||||||
w = sw - dx;
|
|
||||||
h = sh + dy;
|
|
||||||
l = sl + sw - Math.max(PIP_MIN_W, w);
|
|
||||||
}
|
|
||||||
if (corner === 'ne') {
|
|
||||||
w = sw + dx;
|
|
||||||
h = sh - dy;
|
|
||||||
t = st + sh - Math.max(PIP_MIN_H, h);
|
|
||||||
}
|
|
||||||
if (corner === 'nw') {
|
|
||||||
w = sw - dx;
|
|
||||||
h = sh - dy;
|
|
||||||
l = sl + sw - Math.max(PIP_MIN_W, w);
|
|
||||||
t = st + sh - Math.max(PIP_MIN_H, h);
|
|
||||||
}
|
|
||||||
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
|
||||||
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
|
||||||
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
|
||||||
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
|
||||||
el.style.width = `${w}px`;
|
|
||||||
el.style.height = `${h}px`;
|
|
||||||
el.style.left = `${l}px`;
|
|
||||||
el.style.top = `${t}px`;
|
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
@@ -789,6 +1048,38 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResizeTouchStart = (e: React.TouchEvent, corner: Corner) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const el = callEmbedRef.current;
|
||||||
|
if (!el || e.touches.length !== 1) return;
|
||||||
|
normaliseToTopLeft(el);
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const sx = touch.clientX;
|
||||||
|
const sy = touch.clientY;
|
||||||
|
const sw = el.offsetWidth;
|
||||||
|
const sh = el.offsetHeight;
|
||||||
|
const sl = parseFloat(el.style.left);
|
||||||
|
const st = parseFloat(el.style.top);
|
||||||
|
const onMove = (ev: TouchEvent) => {
|
||||||
|
if (ev.touches.length !== 1) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const t = ev.touches[0];
|
||||||
|
applyResize(el, corner, sx, sy, sw, sh, sl, st, t.clientX, t.clientY);
|
||||||
|
};
|
||||||
|
const onEnd = () => {
|
||||||
|
document.removeEventListener('touchmove', onMove);
|
||||||
|
document.removeEventListener('touchend', onEnd);
|
||||||
|
activeDragCleanupRef.current = null;
|
||||||
|
};
|
||||||
|
activeDragCleanupRef.current = () => {
|
||||||
|
document.removeEventListener('touchmove', onMove);
|
||||||
|
document.removeEventListener('touchend', onEnd);
|
||||||
|
};
|
||||||
|
document.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', onEnd);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CallEmbedContextProvider value={callEmbed}>
|
<CallEmbedContextProvider value={callEmbed}>
|
||||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||||
@@ -834,19 +1125,43 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
padding: '6px',
|
padding: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||||
style={{
|
{document.fullscreenEnabled && (
|
||||||
background: 'rgba(0,0,0,0.65)',
|
<IconButton
|
||||||
backdropFilter: 'blur(4px)',
|
type="button"
|
||||||
borderRadius: '6px',
|
size="300"
|
||||||
padding: '3px 8px',
|
radii="300"
|
||||||
color: '#fff',
|
variant="Surface"
|
||||||
fontSize: '11px',
|
fill="None"
|
||||||
fontWeight: 600,
|
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||||
pointerEvents: 'none',
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
handlePipFullscreen();
|
||||||
↗ Return to call
|
}}
|
||||||
|
style={{
|
||||||
|
// Dark scrim is intentional for legibility over arbitrary video.
|
||||||
|
background: 'rgba(0,0,0,0.65)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pipIsFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0,0,0,0.65)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↗ Return to call
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PipMuteOverlay callEmbed={callEmbed} />
|
<PipMuteOverlay callEmbed={callEmbed} />
|
||||||
@@ -871,6 +1186,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
<div
|
<div
|
||||||
key={corner}
|
key={corner}
|
||||||
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
|
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
|
||||||
|
onTouchStart={(ev) => handleResizeTouchStart(ev, corner)}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Verifier,
|
Verifier,
|
||||||
} from 'matrix-js-sdk/lib/crypto-api';
|
} from 'matrix-js-sdk/lib/crypto-api';
|
||||||
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
|
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
} from '../hooks/useVerificationRequest';
|
} from '../hooks/useVerificationRequest';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
|
|
||||||
const DialogHeaderStyles: CSSProperties = {
|
const DialogHeaderStyles: CSSProperties = {
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
@@ -50,21 +52,23 @@ function WaitingMessage({ message }: WaitingMessageProps) {
|
|||||||
|
|
||||||
type VerificationUnexpectedProps = { message: string; onClose: () => void };
|
type VerificationUnexpectedProps = { message: string; onClose: () => void };
|
||||||
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
|
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>{message}</Text>
|
<Text>{message}</Text>
|
||||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||||
<Text size="B400">Close</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitAccept() {
|
function VerificationWaitAccept() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Please accept the request from other device.</Text>
|
<Text>{t('Organisms.DeviceVerification.please_accept')}</Text>
|
||||||
<WaitingMessage message="Waiting for request to be accepted..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_accept')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,12 +77,13 @@ type VerificationAcceptProps = {
|
|||||||
onAccept: () => Promise<void>;
|
onAccept: () => Promise<void>;
|
||||||
};
|
};
|
||||||
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [acceptState, accept] = useAsyncCallback(onAccept);
|
const [acceptState, accept] = useAsyncCallback(onAccept);
|
||||||
|
|
||||||
const accepting = acceptState.status === AsyncStatus.Loading;
|
const accepting = acceptState.status === AsyncStatus.Loading;
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Click accept to start the verification process.</Text>
|
<Text>{t('Organisms.DeviceVerification.click_accept')}</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
@@ -86,17 +91,18 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
|||||||
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
|
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
|
||||||
disabled={accepting}
|
disabled={accepting}
|
||||||
>
|
>
|
||||||
<Text size="B400">Accept</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.accept')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitStart() {
|
function VerificationWaitStart() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Verification request has been accepted.</Text>
|
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
|
||||||
<WaitingMessage message="Waiting for the response from other device..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,18 +111,20 @@ type VerificationStartProps = {
|
|||||||
onStart: () => Promise<void>;
|
onStart: () => Promise<void>;
|
||||||
};
|
};
|
||||||
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStart();
|
onStart();
|
||||||
}, [onStart]);
|
}, [onStart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
||||||
|
|
||||||
const confirming =
|
const confirming =
|
||||||
@@ -124,7 +132,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
<Text>{t('Organisms.DeviceVerification.confirm_emoji')}</Text>
|
||||||
<Box
|
<Box
|
||||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||||
style={{
|
style={{
|
||||||
@@ -156,7 +164,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|||||||
disabled={confirming}
|
disabled={confirming}
|
||||||
before={confirming && <Spinner size="100" variant="Primary" />}
|
before={confirming && <Spinner size="100" variant="Primary" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">They Match</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.they_match')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
@@ -164,7 +172,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|||||||
onClick={() => sasData.mismatch()}
|
onClick={() => sasData.mismatch()}
|
||||||
disabled={confirming}
|
disabled={confirming}
|
||||||
>
|
>
|
||||||
<Text size="B400">Do not Match</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.do_not_match')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -176,6 +184,7 @@ type SasVerificationProps = {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
||||||
|
|
||||||
useVerifierShowSas(verifier, setSasData);
|
useVerifierShowSas(verifier, setSasData);
|
||||||
@@ -191,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,13 +209,14 @@ type VerificationDoneProps = {
|
|||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
};
|
};
|
||||||
function VerificationDone({ onExit }: VerificationDoneProps) {
|
function VerificationDone({ onExit }: VerificationDoneProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<div>
|
<div>
|
||||||
<Text>Your device is verified.</Text>
|
<Text>{t('Organisms.DeviceVerification.device_verified')}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
||||||
<Text size="B400">Okay</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -216,11 +226,12 @@ type VerificationCanceledProps = {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Verification has been canceled.</Text>
|
<Text>{t('Organisms.DeviceVerification.verification_canceled')}</Text>
|
||||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||||
<Text size="B400">Close</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -232,6 +243,7 @@ type DeviceVerificationProps = {
|
|||||||
};
|
};
|
||||||
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
|
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
|
||||||
const phase = useVerificationRequestPhase(request);
|
const phase = useVerificationRequestPhase(request);
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
|
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
|
||||||
@@ -255,7 +267,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
|||||||
escapeDeactivates: false,
|
escapeDeactivates: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog variant="Surface">
|
<Dialog variant="Surface" style={modalStyle}>
|
||||||
<Header style={DialogHeaderStyles} variant="Surface" size="500">
|
<Header style={DialogHeaderStyles} variant="Surface" size="500">
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text as="h2" size="H4">
|
<Text as="h2" size="H4">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
||||||
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
import { copyToClipboard } from '../utils/dom';
|
import { copyToClipboard } from '../utils/dom';
|
||||||
@@ -287,9 +288,10 @@ type DeviceVerificationSetupProps = {
|
|||||||
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
|
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
|
||||||
({ onCancel }, ref) => {
|
({ onCancel }, ref) => {
|
||||||
const [recoveryKey, setRecoveryKey] = useState<string>();
|
const [recoveryKey, setRecoveryKey] = useState<string>();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog ref={ref}>
|
<Dialog ref={ref} style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
@@ -324,9 +326,10 @@ type DeviceVerificationResetProps = {
|
|||||||
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
|
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
|
||||||
({ onCancel }, ref) => {
|
({ onCancel }, ref) => {
|
||||||
const [reset, setReset] = useState(false);
|
const [reset, setReset] = useState(false);
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog ref={ref}>
|
<Dialog ref={ref} style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
|
|||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||||
import { IGif } from '@giphy/js-types';
|
import { IGif } from '@giphy/js-types';
|
||||||
import { Box } from 'folds';
|
import { Box, color, config } from 'folds';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
|
||||||
@@ -36,12 +36,12 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 10px 4px',
|
padding: '5px 10px 4px',
|
||||||
borderBottom: '1px solid rgba(255,107,0,0.2)',
|
borderBottom: '1px solid color-mix(in srgb, var(--lt-accent-orange) 20%, transparent)',
|
||||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.1em',
|
||||||
color: '#FF6B00',
|
color: 'var(--lt-accent-orange)',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -82,19 +82,20 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
|||||||
|
|
||||||
const containerStyle = lotusTerminal
|
const containerStyle = lotusTerminal
|
||||||
? {
|
? {
|
||||||
background: '#060c14',
|
background: 'var(--lt-bg-secondary)',
|
||||||
border: '1px solid rgba(255,107,0,0.35)',
|
border: '1px solid color-mix(in srgb, var(--lt-accent-orange) 35%, transparent)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: '0 4px 24px rgba(255,107,0,0.10), 0 0 0 1px rgba(255,107,0,0.08)',
|
boxShadow:
|
||||||
|
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
|
||||||
width: `${PICKER_WIDTH}px`,
|
width: `${PICKER_WIDTH}px`,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
background: 'var(--bg-surface)',
|
background: color.Surface.Container,
|
||||||
border: '1px solid rgba(255,255,255,0.08)',
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: '12px',
|
borderRadius: config.radii.R400,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
boxShadow: color.Other.Shadow,
|
||||||
width: `${PICKER_WIDTH}px`,
|
width: `${PICKER_WIDTH}px`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
|
returnFocusOnDeactivate: false,
|
||||||
onDeactivate: requestClose,
|
onDeactivate: requestClose,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
allowOutsideClick: true,
|
allowOutsideClick: true,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { logoutClient } from '../../client/initMatrix';
|
import { logoutClient } from '../../client/initMatrix';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||||
import { InfoCard } from './info-card';
|
import { InfoCard } from './info-card';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,7 @@ type LogoutDialogProps = {
|
|||||||
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||||
({ handleClose }, ref) => {
|
({ handleClose }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
|
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
|
||||||
const crossSigningActive = useCrossSigningActive();
|
const crossSigningActive = useCrossSigningActive();
|
||||||
const verificationStatus = useDeviceVerificationStatus(
|
const verificationStatus = useDeviceVerificationStatus(
|
||||||
@@ -33,7 +35,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog variant="Surface" ref={ref}>
|
<Dialog variant="Surface" ref={ref} style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
import { color, Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
|
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
|
||||||
|
|
||||||
type MemberVerificationBadgeProps = {
|
type MemberVerificationBadgeProps = {
|
||||||
@@ -9,8 +9,7 @@ type MemberVerificationBadgeProps = {
|
|||||||
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
||||||
const vs = useUserVerifiedStatus(userId);
|
const vs = useUserVerifiedStatus(userId);
|
||||||
if (vs === 'unknown') return null;
|
if (vs === 'unknown') return null;
|
||||||
const color =
|
const iconColor = vs === 'verified' ? color.Success.Main : color.Warning.Main;
|
||||||
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
|
|
||||||
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -27,7 +26,7 @@ export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps
|
|||||||
title={label}
|
title={label}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Icon size="100" src={Icons.ShieldUser} style={{ color }} />
|
<Icon size="100" src={Icons.ShieldUser} style={{ color: iconColor }} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import React, { ReactNode } from 'react';
|
|||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
|
|
||||||
type Modal500Props = {
|
type Modal500Props = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||||
|
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -19,7 +21,25 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal size="500" variant="Background">
|
<Modal
|
||||||
|
size="500"
|
||||||
|
variant="Background"
|
||||||
|
// On mobile expand to fill the viewport. On desktop fall back to the
|
||||||
|
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
||||||
|
// squish the two-pane settings layout.
|
||||||
|
style={
|
||||||
|
isMobile
|
||||||
|
? {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
borderRadius: 0,
|
||||||
|
overflow: 'hidden auto',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
|
|||||||
@@ -232,7 +232,18 @@ export function RenderMessageContent({
|
|||||||
<ThumbnailContent
|
<ThumbnailContent
|
||||||
info={info}
|
info={info}
|
||||||
renderImage={(src) => (
|
renderImage={(src) => (
|
||||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
<Image
|
||||||
|
alt={body}
|
||||||
|
title={body}
|
||||||
|
src={src}
|
||||||
|
loading="lazy"
|
||||||
|
style={{
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center top',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useTauriCallPower } from '../hooks/useTauriCallPower';
|
||||||
|
import { useTauriJumpList } from '../hooks/useTauriJumpList';
|
||||||
|
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
||||||
|
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
||||||
|
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||||
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
|
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
||||||
|
* `useTauri*` hook no-ops in the browser (guards on `isTauri`), so this is safe
|
||||||
|
* to render unconditionally. Rendered once by `ClientNonUIFeatures`. App-level
|
||||||
|
* desktop features (window chrome) live in `App.tsx` instead, so they work
|
||||||
|
* before login.
|
||||||
|
*/
|
||||||
|
export function TauriDesktopFeatures(): null {
|
||||||
|
useTauriCallPower(); // P5-46 no-sleep during calls
|
||||||
|
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
|
||||||
|
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
||||||
|
useTauriSmtc(); // P5-43 system media transport controls
|
||||||
|
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
||||||
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
|
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Icon, IconButton, Icons, Text, config, toRem } from 'folds';
|
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
|
||||||
@@ -51,6 +51,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
|
|
||||||
const previewMimeRef = useRef('audio/ogg;codecs=opus');
|
const previewMimeRef = useRef('audio/ogg;codecs=opus');
|
||||||
const previewDurationRef = useRef(0);
|
const previewDurationRef = useRef(0);
|
||||||
|
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [previewPlaying, setPreviewPlaying] = useState(false);
|
||||||
|
|
||||||
const stopAll = useCallback(() => {
|
const stopAll = useCallback(() => {
|
||||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||||
@@ -192,7 +194,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="200"
|
gap="200"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
}}
|
}}
|
||||||
@@ -203,7 +205,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
width: toRem(8),
|
width: toRem(8),
|
||||||
height: toRem(8),
|
height: toRem(8),
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: lotusTerminal ? '#FF6B00' : 'var(--tc-danger-normal)',
|
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
||||||
}}
|
}}
|
||||||
@@ -214,7 +216,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
minWidth: toRem(32),
|
minWidth: toRem(32),
|
||||||
fontVariantNumeric: 'tabular-nums',
|
fontVariantNumeric: 'tabular-nums',
|
||||||
...(lotusTerminal
|
...(lotusTerminal
|
||||||
? { fontFamily: 'JetBrains Mono, monospace', color: '#00FF88', fontWeight: 700 }
|
? {
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
color: 'var(--lt-accent-green)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -233,7 +239,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
width: toRem(2),
|
width: toRem(2),
|
||||||
height: toRem(2 + (h / barMax) * 16),
|
height: toRem(2 + (h / barMax) * 16),
|
||||||
borderRadius: toRem(1),
|
borderRadius: toRem(1),
|
||||||
background: lotusTerminal ? '#00FF88' : 'var(--tc-primary-normal)',
|
background: lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -269,13 +275,36 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="200"
|
gap="200"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
padding: `${toRem(4)} ${toRem(8)}`,
|
padding: `${toRem(4)} ${toRem(8)}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<audio src={previewUrl} controls style={{ height: toRem(28), maxWidth: toRem(180) }} />
|
<>
|
||||||
|
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} />
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
const audio = previewAudioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (previewPlaying) {
|
||||||
|
audio.pause();
|
||||||
|
setPreviewPlaying(false);
|
||||||
|
} else {
|
||||||
|
audio.play();
|
||||||
|
setPreviewPlaying(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={previewPlaying ? 'Pause preview' : 'Play preview'}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
title={previewPlaying ? 'Pause' : 'Play'}
|
||||||
|
>
|
||||||
|
<Icon src={previewPlaying ? Icons.Pause : Icons.Play} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Text size="T200" style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>
|
<Text size="T200" style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>
|
||||||
{formatDuration(previewDurationRef.current)}
|
{formatDuration(previewDurationRef.current)}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||||
|
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
||||||
|
|
||||||
|
const DEFAULT_INSET = 8;
|
||||||
|
|
||||||
|
type AvatarDecorationProps = {
|
||||||
|
userId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
inset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AvatarDecoration({
|
||||||
|
userId,
|
||||||
|
children,
|
||||||
|
inset = DEFAULT_INSET,
|
||||||
|
}: AvatarDecorationProps) {
|
||||||
|
const slug = useAvatarDecoration(userId);
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<img
|
||||||
|
src={decorationUrl(slug)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -inset,
|
||||||
|
left: -inset,
|
||||||
|
right: -inset,
|
||||||
|
bottom: -inset,
|
||||||
|
width: `calc(100% + ${inset * 2}px)`,
|
||||||
|
height: `calc(100% + ${inset * 2}px)`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 10,
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
|
||||||
import { SequenceCard } from '../sequence-card';
|
import { SequenceCard } from '../sequence-card';
|
||||||
import { SettingTile } from '../setting-tile';
|
import { SettingTile } from '../setting-tile';
|
||||||
@@ -17,6 +18,7 @@ export function CreateRoomTypeSelector({
|
|||||||
disabled,
|
disabled,
|
||||||
getIcon,
|
getIcon,
|
||||||
}: CreateRoomTypeSelectorProps) {
|
}: CreateRoomTypeSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
@@ -36,10 +38,10 @@ export function CreateRoomTypeSelector({
|
|||||||
>
|
>
|
||||||
<Box gap="200" alignItems="Baseline">
|
<Box gap="200" alignItems="Baseline">
|
||||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||||
Chat Room
|
{t('Organisms.CreateRoom.chat_room')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T300" priority="300" truncate>
|
<Text size="T300" priority="300" truncate>
|
||||||
- Messages, photos, and videos.
|
- {t('Organisms.CreateRoom.chat_room_desc')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</SettingTile>
|
</SettingTile>
|
||||||
@@ -61,10 +63,10 @@ export function CreateRoomTypeSelector({
|
|||||||
>
|
>
|
||||||
<Box gap="200" alignItems="Baseline">
|
<Box gap="200" alignItems="Baseline">
|
||||||
<Text size="H6" style={{ flexShrink: 0 }}>
|
<Text size="H6" style={{ flexShrink: 0 }}>
|
||||||
Voice Room
|
{t('Organisms.CreateRoom.voice_room')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T300" priority="300" truncate>
|
<Text size="T300" priority="300" truncate>
|
||||||
- Live audio and video conversations.
|
- {t('Organisms.CreateRoom.voice_room_desc')}
|
||||||
</Text>
|
</Text>
|
||||||
<BetaNoticeBadge />
|
<BetaNoticeBadge />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
aria-label="Exit formatting"
|
||||||
>
|
>
|
||||||
<Text size="B400">{`Exit ${KeySymbol.Hyper}`}</Text>
|
<Text size="B400">{`Exit ${KeySymbol.Hyper}`}</Text>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { UserAvatar } from '../../user-avatar';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { Membership } from '../../../../types/matrix/room';
|
import { Membership } from '../../../../types/matrix/room';
|
||||||
import { PresenceRingAvatar } from '../../presence';
|
import { PresenceRingAvatar } from '../../presence';
|
||||||
|
import { AvatarDecoration } from '../../avatar-decoration/AvatarDecoration';
|
||||||
|
|
||||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||||
|
|
||||||
@@ -48,14 +49,16 @@ function UnknownMentionItem({
|
|||||||
}
|
}
|
||||||
onClick={() => handleAutocomplete(userId, name)}
|
onClick={() => handleAutocomplete(userId, name)}
|
||||||
before={
|
before={
|
||||||
<PresenceRingAvatar userId={userId}>
|
<AvatarDecoration userId={userId}>
|
||||||
<Avatar size="200">
|
<PresenceRingAvatar userId={userId}>
|
||||||
<UserAvatar
|
<Avatar size="200">
|
||||||
userId={userId}
|
<UserAvatar
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
userId={userId}
|
||||||
/>
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
</Avatar>
|
/>
|
||||||
</PresenceRingAvatar>
|
</Avatar>
|
||||||
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} size="B400">
|
<Text style={{ flexGrow: 1 }} size="B400">
|
||||||
@@ -177,16 +180,18 @@ export function UserMentionAutocomplete({
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
<PresenceRingAvatar userId={roomMember.userId}>
|
<AvatarDecoration userId={roomMember.userId}>
|
||||||
<Avatar size="200">
|
<PresenceRingAvatar userId={roomMember.userId}>
|
||||||
<UserAvatar
|
<Avatar size="200">
|
||||||
userId={roomMember.userId}
|
<UserAvatar
|
||||||
src={avatarUrl ?? undefined}
|
userId={roomMember.userId}
|
||||||
alt={getName(roomMember)}
|
src={avatarUrl ?? undefined}
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
alt={getName(roomMember)}
|
||||||
/>
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
</Avatar>
|
/>
|
||||||
</PresenceRingAvatar>
|
</Avatar>
|
||||||
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
|||||||
@@ -178,6 +178,16 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
const icons = useEmojiGroupIcons();
|
const icons = useEmojiGroupIcons();
|
||||||
|
|
||||||
|
const packLabels = useMemo(() => {
|
||||||
|
const map = new Map<string, string | undefined>();
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
let label = pack.meta.name;
|
||||||
|
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||||
|
map.set(pack.id, label);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [mx, packs]);
|
||||||
|
|
||||||
const handleScrollToGroup = (groupId: string) => {
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
setActiveGroupId(groupId);
|
setActiveGroupId(groupId);
|
||||||
onScrollToGroup(groupId);
|
onScrollToGroup(groupId);
|
||||||
@@ -198,8 +208,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<SidebarDivider />
|
<SidebarDivider />
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
const label = packLabels.get(pack.id);
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
||||||
@@ -252,6 +261,16 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||||
const usage = ImageUsage.Sticker;
|
const usage = ImageUsage.Sticker;
|
||||||
|
|
||||||
|
const packLabels = useMemo(() => {
|
||||||
|
const map = new Map<string, string | undefined>();
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
let label = pack.meta.name;
|
||||||
|
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||||
|
map.set(pack.id, label);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [mx, packs]);
|
||||||
|
|
||||||
const handleScrollToGroup = (groupId: string) => {
|
const handleScrollToGroup = (groupId: string) => {
|
||||||
setActiveGroupId(groupId);
|
setActiveGroupId(groupId);
|
||||||
onScrollToGroup(groupId);
|
onScrollToGroup(groupId);
|
||||||
@@ -261,8 +280,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
|||||||
<Sidebar>
|
<Sidebar>
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
{packs.map((pack) => {
|
{packs.map((pack) => {
|
||||||
let label = pack.meta.name;
|
const label = packLabels.get(pack.id);
|
||||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
|
||||||
|
|
||||||
const url =
|
const url =
|
||||||
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
|
||||||
|
|||||||
@@ -67,12 +67,12 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
<Header
|
<Header
|
||||||
className={css.Header}
|
className={css.Header}
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
size="600"
|
size="500"
|
||||||
style={
|
style={
|
||||||
lotusTerminal
|
lotusTerminal
|
||||||
? {
|
? {
|
||||||
borderBottom: '1px solid rgba(0,212,255,0.30)',
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
boxShadow: '0 2px 12px rgba(0,212,255,0.08)',
|
boxShadow: 'var(--lt-box-glow-cyan)',
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -83,8 +83,8 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
style={
|
style={
|
||||||
lotusTerminal
|
lotusTerminal
|
||||||
? {
|
? {
|
||||||
color: '#00D4FF',
|
color: 'var(--lt-accent-cyan)',
|
||||||
textShadow: '0 0 6px rgba(0,212,255,0.45)',
|
textShadow: 'var(--lt-glow-cyan)',
|
||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@@ -93,7 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
Seen by
|
Seen by
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={requestClose} aria-label="Close">
|
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
@@ -141,14 +141,14 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||||||
{receiptTs !== undefined && (
|
{receiptTs !== undefined && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
|
priority="300"
|
||||||
style={
|
style={
|
||||||
lotusTerminal
|
lotusTerminal
|
||||||
? {
|
? {
|
||||||
color: '#FFB300',
|
color: 'var(--lt-accent-amber)',
|
||||||
textShadow: '0 0 5px rgba(255,179,0,0.45)',
|
textShadow: 'var(--lt-glow-amber)',
|
||||||
fontSize: '0.72rem',
|
|
||||||
}
|
}
|
||||||
: { opacity: 0.6 }
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formatReadTs(receiptTs, hour24Clock)}
|
{formatReadTs(receiptTs, hour24Clock)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
@@ -15,6 +16,7 @@ export type ImageViewerProps = {
|
|||||||
|
|
||||||
export const ImageViewer = as<'div', ImageViewerProps>(
|
export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
({ className, alt, src, requestClose, ...props }, ref) => {
|
({ className, alt, src, requestClose, ...props }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||||||
radii="300"
|
radii="300"
|
||||||
before={<Icon size="50" src={Icons.Download} />}
|
before={<Icon size="50" src={Icons.Download} />}
|
||||||
>
|
>
|
||||||
<Text size="B300">Download</Text>
|
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
@@ -51,6 +52,7 @@ import { useAlive } from '../../hooks/useAlive';
|
|||||||
import { copyToClipboard } from '../../utils/dom';
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
@@ -65,7 +67,9 @@ type InviteUserProps = {
|
|||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const modalStyle = useModalStyle(560);
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
const [linkCopied, setLinkCopied] = useState(false);
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
const [showQr, setShowQr] = useState(false);
|
const [showQr, setShowQr] = useState(false);
|
||||||
@@ -184,7 +188,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog>
|
<Dialog style={modalStyle}>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Header
|
<Header
|
||||||
size="500"
|
size="500"
|
||||||
@@ -192,7 +196,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
Invite
|
{t('Organisms.InviteUser.invite')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="100" alignItems="Center">
|
<Box shrink="No" gap="100" alignItems="Center">
|
||||||
@@ -349,7 +353,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
disabled={!validUserId || inviting}
|
disabled={!validUserId || inviting}
|
||||||
before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />}
|
before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">Invite</Text>
|
<Text size="B400">{t('Organisms.InviteUser.invite')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { isRoomAlias, isRoomId } from '../../utils/matrix';
|
import { isRoomAlias, isRoomId } from '../../utils/matrix';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
|
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
|
||||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ type JoinAddressProps = {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
const [invalid, setInvalid] = useState(false);
|
const [invalid, setInvalid] = useState(false);
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -71,7 +73,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog variant="Surface">
|
<Dialog variant="Surface" style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
type LeaveRoomPromptProps = {
|
type LeaveRoomPromptProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -28,6 +29,7 @@ type LeaveRoomPromptProps = {
|
|||||||
};
|
};
|
||||||
export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
|
export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
|
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -56,7 +58,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog variant="Surface" aria-labelledby="leave-room-dialog-title">
|
<Dialog variant="Surface" aria-labelledby="leave-room-dialog-title" style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
type LeaveSpacePromptProps = {
|
type LeaveSpacePromptProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -28,6 +29,7 @@ type LeaveSpacePromptProps = {
|
|||||||
};
|
};
|
||||||
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
|
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
|
|
||||||
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
|
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -56,7 +58,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog variant="Surface">
|
<Dialog variant="Surface" style={modalStyle}>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const Image = style([
|
|||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center top',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Button, Chip, Icon, Icons, Text, color, toRem } from 'folds';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
|
||||||
import { IContent } from 'matrix-js-sdk';
|
import { IContent } from 'matrix-js-sdk';
|
||||||
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
|
||||||
import { trimReplyFromBody } from '../../utils/room';
|
import { trimReplyFromBody } from '../../utils/room';
|
||||||
@@ -94,15 +95,21 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{needsCollapse && (
|
{needsCollapse && (
|
||||||
<Button
|
<button
|
||||||
size="300"
|
type="button"
|
||||||
variant="Secondary"
|
|
||||||
fill="None"
|
|
||||||
style={{ marginTop: '4px' }}
|
|
||||||
onClick={() => setCollapsed((c) => !c)}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
marginTop: config.space.S100,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text size="B300">{collapsed ? 'Read more ↓' : 'Show less ↑'}</Text>
|
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
|
||||||
</Button>
|
{collapsed ? 'Read more ↓' : 'Show less ↑'}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -501,6 +508,7 @@ type MLocationProps = {
|
|||||||
content: IContent;
|
content: IContent;
|
||||||
};
|
};
|
||||||
export function MLocation({ content }: MLocationProps) {
|
export function MLocation({ content }: MLocationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const geoUri = content.geo_uri;
|
const geoUri = content.geo_uri;
|
||||||
if (typeof geoUri !== 'string') return <BrokenContent />;
|
if (typeof geoUri !== 'string') return <BrokenContent />;
|
||||||
const location = parseGeoUri(geoUri);
|
const location = parseGeoUri(geoUri);
|
||||||
@@ -521,7 +529,7 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
style={{
|
style={{
|
||||||
width: '280px',
|
width: '280px',
|
||||||
height: '160px',
|
height: '160px',
|
||||||
border: '1px solid var(--bg-surface-border)',
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
}}
|
}}
|
||||||
@@ -529,21 +537,22 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
sandbox="allow-scripts"
|
sandbox="allow-scripts"
|
||||||
/>
|
/>
|
||||||
<Text size="T300" style={{ opacity: 0.65 }}>
|
<Text size="T300" priority="300">
|
||||||
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
|
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
|
||||||
</Text>
|
</Text>
|
||||||
<Chip
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
size="400"
|
size="400"
|
||||||
href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
|
href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
variant="Primary"
|
variant="Secondary"
|
||||||
radii="Pill"
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
before={<Icon src={Icons.External} size="50" />}
|
before={<Icon src={Icons.External} size="50" />}
|
||||||
>
|
>
|
||||||
<Text size="B300">Open Location</Text>
|
<Text size="B300">{t('Organisms.Message.open_location')}</Text>
|
||||||
</Chip>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,34 +15,42 @@ export const Reaction = as<
|
|||||||
reaction: string;
|
reaction: string;
|
||||||
useAuthentication?: boolean;
|
useAuthentication?: boolean;
|
||||||
}
|
}
|
||||||
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
|
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => {
|
||||||
<Box
|
const shortcode = reaction.startsWith('mxc://')
|
||||||
as="button"
|
? 'custom emoji'
|
||||||
className={classNames(css.Reaction, className)}
|
: (getShortcodeFor(getHexcodeForEmoji(reaction)) ?? reaction);
|
||||||
alignItems="Center"
|
const label = `${shortcode} reaction, ${count} ${count === 1 ? 'person' : 'people'}`;
|
||||||
shrink="No"
|
|
||||||
gap="200"
|
return (
|
||||||
{...props}
|
<Box
|
||||||
ref={ref}
|
as="button"
|
||||||
>
|
className={classNames(css.Reaction, className)}
|
||||||
<Text className={css.ReactionText} as="span" size="T400">
|
alignItems="Center"
|
||||||
{reaction.startsWith('mxc://') ? (
|
shrink="No"
|
||||||
<img
|
gap="200"
|
||||||
className={css.ReactionImg}
|
aria-label={label}
|
||||||
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction}
|
{...props}
|
||||||
alt={reaction}
|
ref={ref}
|
||||||
/>
|
>
|
||||||
) : (
|
<Text className={css.ReactionText} as="span" size="T400">
|
||||||
<Text as="span" size="Inherit" truncate>
|
{reaction.startsWith('mxc://') ? (
|
||||||
{reaction}
|
<img
|
||||||
</Text>
|
className={css.ReactionImg}
|
||||||
)}
|
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction}
|
||||||
</Text>
|
alt={reaction}
|
||||||
<Text as="span" size="T300">
|
/>
|
||||||
{count}
|
) : (
|
||||||
</Text>
|
<Text as="span" size="Inherit" truncate>
|
||||||
</Box>
|
{reaction}
|
||||||
));
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text as="span" size="T300">
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
type ReactionTooltipMsgProps = {
|
type ReactionTooltipMsgProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
|
||||||
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
import { EventTimelineSet, Room } from 'matrix-js-sdk';
|
||||||
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
@@ -37,19 +38,22 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => {
|
||||||
<Box
|
const { t } = useTranslation();
|
||||||
shrink="No"
|
return (
|
||||||
className={css.ThreadIndicator}
|
<Box
|
||||||
alignItems="Center"
|
shrink="No"
|
||||||
gap="100"
|
className={css.ThreadIndicator}
|
||||||
{...props}
|
alignItems="Center"
|
||||||
ref={ref}
|
gap="100"
|
||||||
>
|
{...props}
|
||||||
<Icon size="50" src={Icons.Thread} />
|
ref={ref}
|
||||||
<Text size="L400">Thread</Text>
|
>
|
||||||
</Box>
|
<Icon size="50" src={Icons.Thread} />
|
||||||
));
|
<Text size="L400">{t('Organisms.Message.thread')}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
type ReplyProps = {
|
type ReplyProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -103,10 +107,16 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
return (
|
return (
|
||||||
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||||
{threadRootId && (
|
{threadRootId && (
|
||||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
<ThreadIndicator
|
||||||
|
as="button"
|
||||||
|
data-event-id={threadRootId}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label="View thread"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<ReplyLayout
|
<ReplyLayout
|
||||||
as="button"
|
as="button"
|
||||||
|
aria-label="Jump to original message"
|
||||||
userColor={usernameColor}
|
userColor={usernameColor}
|
||||||
username={
|
username={
|
||||||
sender && (
|
sender && (
|
||||||
|
|||||||
@@ -182,8 +182,8 @@ export function AudioContent({
|
|||||||
|
|
||||||
<Chip
|
<Chip
|
||||||
onClick={handleSpeedClick}
|
onClick={handleSpeedClick}
|
||||||
variant="SurfaceVariant"
|
variant="Secondary"
|
||||||
radii="Pill"
|
radii="300"
|
||||||
aria-label={`Playback speed: ${playbackSpeed}×`}
|
aria-label={`Playback speed: ${playbackSpeed}×`}
|
||||||
>
|
>
|
||||||
<Text size="B300">{`${playbackSpeed}×`}</Text>
|
<Text size="B300">{`${playbackSpeed}×`}</Text>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const MessageEditedContent = as<
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEditHistoryClick}
|
onClick={onEditHistoryClick}
|
||||||
|
aria-label="View edit history"
|
||||||
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
||||||
>
|
>
|
||||||
<Text as="span" size="T200" priority="300">
|
<Text as="span" size="T200" priority="300">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -31,6 +32,7 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -80,11 +82,15 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nearViewport = useNearViewport(sentinelRef);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
@@ -113,11 +119,12 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoPlay) loadSrc().catch(() => undefined);
|
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, nearViewport, loadSrc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
@@ -163,7 +170,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
onClick={loadSrc}
|
onClick={loadSrc}
|
||||||
before={<Icon size="Inherit" src={Icons.Photo} filled />}
|
before={<Icon size="Inherit" src={Icons.Photo} filled />}
|
||||||
>
|
>
|
||||||
<Text size="B300">View</Text>
|
<Text size="B300">{t('Organisms.ImageContent.view')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -207,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="B300">Spoiler</Text>
|
<Text size="B300">{t('Organisms.ImageContent.spoiler')}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -242,7 +249,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
|||||||
onClick={handleRetry}
|
onClick={handleRetry}
|
||||||
before={<Icon size="Inherit" src={Icons.Warning} filled />}
|
before={<Icon size="Inherit" src={Icons.Warning} filled />}
|
||||||
>
|
>
|
||||||
<Text size="B300">Retry</Text>
|
<Text size="B300">{t('Organisms.ImageContent.retry')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Text } from 'folds';
|
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import { RoomEvent } from 'matrix-js-sdk';
|
import { RoomEvent } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
@@ -175,7 +175,7 @@ export function PollContent({
|
|||||||
|
|
||||||
if (!poll) {
|
if (!poll) {
|
||||||
return (
|
return (
|
||||||
<Text style={{ opacity: 0.6 }}>
|
<Text priority="300">
|
||||||
<i>Poll (unreadable format)</i>
|
<i>Poll (unreadable format)</i>
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -244,21 +244,20 @@ export function PollContent({
|
|||||||
gap="200"
|
gap="200"
|
||||||
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
|
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
|
||||||
>
|
>
|
||||||
<Box
|
<Text
|
||||||
alignItems="Center"
|
as="div"
|
||||||
gap="100"
|
size="T200"
|
||||||
|
priority="300"
|
||||||
data-poll-content-label
|
data-poll-content-label
|
||||||
style={{
|
style={{
|
||||||
fontSize: '0.68rem',
|
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '0.12em',
|
letterSpacing: '0.12em',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
opacity: 0.55,
|
marginBottom: config.space.S100,
|
||||||
marginBottom: '2px',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
||||||
</Box>
|
</Text>
|
||||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||||
{questionText}
|
{questionText}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -280,20 +279,19 @@ export function PollContent({
|
|||||||
data-selected={selected}
|
data-selected={selected}
|
||||||
onClick={canVote ? () => handleVote(id) : undefined}
|
onClick={canVote ? () => handleVote(id) : undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '7px 12px',
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
borderRadius: '8px',
|
borderRadius: config.radii.R300,
|
||||||
background: selected
|
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||||
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.12)'
|
border: `${config.borderWidth.B300} solid ${
|
||||||
: 'rgba(255,255,255,0.04)',
|
selected ? color.Primary.Main : color.SurfaceVariant.ContainerLine
|
||||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.7)' : 'rgba(255,255,255,0.12)'}`,
|
}`,
|
||||||
fontSize: '0.88rem',
|
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
cursor: canVote ? 'pointer' : 'default',
|
cursor: canVote ? 'pointer' : 'default',
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '4px',
|
gap: config.space.S100,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -309,59 +307,54 @@ export function PollContent({
|
|||||||
right: 'auto',
|
right: 'auto',
|
||||||
width: `${pct}%`,
|
width: `${pct}%`,
|
||||||
background: selected
|
background: selected
|
||||||
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.08)'
|
? color.Primary.ContainerActive
|
||||||
: 'rgba(255,255,255,0.03)',
|
: color.SurfaceVariant.ContainerActive,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
transition: 'width 0.3s ease',
|
transition: 'width 0.3s ease',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', position: 'relative' }}
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isMultiple && (
|
<span
|
||||||
<span
|
style={{
|
||||||
style={{
|
flexShrink: 0,
|
||||||
flexShrink: 0,
|
width: toRem(14),
|
||||||
width: '14px',
|
height: toRem(14),
|
||||||
height: '14px',
|
border: `${config.borderWidth.B300} solid ${
|
||||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||||
borderRadius: '3px',
|
}`,
|
||||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||||
display: 'flex',
|
background: selected ? color.Primary.Main : 'transparent',
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
fontSize: '10px',
|
justifyContent: 'center',
|
||||||
color: '#fff',
|
color: color.Primary.OnMain,
|
||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected ? '✓' : ''}
|
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
|
||||||
</span>
|
</span>
|
||||||
)}
|
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||||
{!isMultiple && (
|
{text}
|
||||||
<span
|
</Text>
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
width: '14px',
|
|
||||||
height: '14px',
|
|
||||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
|
||||||
transition: 'all 0.15s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span style={{ flexGrow: 1 }}>{text}</span>
|
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<span style={{ opacity: 0.55, fontSize: '0.78rem', flexShrink: 0 }}>{pct}%</span>
|
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||||
|
{pct}%
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="T200" style={{ opacity: 0.5, marginTop: '2px' }}>
|
<Text size="T200" priority="300" style={{ marginTop: '2px' }}>
|
||||||
<i>
|
<i>
|
||||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
||||||
{canVote
|
{canVote
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from '../../../utils/matrix';
|
} from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { useNearViewport } from '../../../hooks/useNearViewport';
|
||||||
|
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -79,6 +80,9 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nearViewport = useNearViewport(sentinelRef);
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
@@ -106,11 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoPlay) loadSrc().catch(() => undefined);
|
if (autoPlay && nearViewport) loadSrc().catch(() => undefined);
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, nearViewport, loadSrc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
<div ref={sentinelRef} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }} />
|
||||||
{typeof blurHash === 'string' && !load && (
|
{typeof blurHash === 'string' && !load && (
|
||||||
<BlurhashCanvas
|
<BlurhashCanvas
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
|||||||
@@ -110,10 +110,15 @@ export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
|
|||||||
|
|
||||||
// ── Mention pulse animation ───────────────────────────────────────────────────
|
// ── Mention pulse animation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Animates only `box-shadow` — NOT `transform`. A self-sent @mention message
|
||||||
|
// carries both this class and `MsgAppearClass` (which animates a scale), and two
|
||||||
|
// animations on the same element cannot share the `transform` property: the
|
||||||
|
// later one wins and the other is silently dropped. Pulsing the glow alone keeps
|
||||||
|
// both effects working. (The previous scale(1.003) was imperceptible anyway.)
|
||||||
const mentionPulseKeyframes = keyframes({
|
const mentionPulseKeyframes = keyframes({
|
||||||
'0%': { transform: 'scale(1)', boxShadow: 'none' },
|
'0%': { boxShadow: 'none' },
|
||||||
'30%': { transform: 'scale(1.003)', boxShadow: `0 0 8px ${color.Warning.Main}` },
|
'30%': { boxShadow: `0 0 8px ${color.Warning.Main}` },
|
||||||
'100%': { transform: 'scale(1)', boxShadow: 'none' },
|
'100%': { boxShadow: 'none' },
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ export const PageNav = recipe({
|
|||||||
size: {
|
size: {
|
||||||
'400': {
|
'400': {
|
||||||
width: toRem(256),
|
width: toRem(256),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': { width: '100%' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'300': {
|
'300': {
|
||||||
width: toRem(222),
|
width: toRem(222),
|
||||||
|
'@media': {
|
||||||
|
'(max-width: 750px)': { width: '100%' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -111,6 +117,7 @@ export const PageHeroSection = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
export const PageContentCenter = style([
|
export const PageContentCenter = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import React, { ReactNode, useId } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ type PresenceBadgeProps = {
|
|||||||
};
|
};
|
||||||
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||||
const label = usePresenceLabel();
|
const label = usePresenceLabel();
|
||||||
const badgeLabelId = useId();
|
const ariaLabel = status ? `${label[presence]} — ${status}` : label[presence];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -36,7 +36,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
offset={4}
|
offset={4}
|
||||||
delay={200}
|
delay={200}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip id={badgeLabelId}>
|
<Tooltip>
|
||||||
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||||
<Text size="L400">{label[presence]}</Text>
|
<Text size="L400">{label[presence]}</Text>
|
||||||
{status && <Text size="T200">•</Text>}
|
{status && <Text size="T200">•</Text>}
|
||||||
@@ -47,7 +47,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<Badge
|
<Badge
|
||||||
aria-labelledby={badgeLabelId}
|
aria-label={ariaLabel}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
size={size}
|
size={size}
|
||||||
variant={PresenceToColor[presence]}
|
variant={PresenceToColor[presence]}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { config } from 'folds';
|
||||||
|
|
||||||
|
// Hover/focus emphasis driven by CSS rather than JS style mutation, matching
|
||||||
|
// how every other interactive element in the app handles hover state.
|
||||||
|
export const ReceiptTrigger = style({
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginTop: config.space.S100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S100,
|
||||||
|
opacity: config.opacity.P500,
|
||||||
|
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||||
|
selectors: {
|
||||||
|
'&:hover, &:focus-visible': {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'scale(1.04)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { Icon, Icons, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, color } from 'folds';
|
import {
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
@@ -12,6 +22,8 @@ import { UserAvatar } from '../user-avatar';
|
|||||||
import { StackedAvatar } from '../stacked-avatar';
|
import { StackedAvatar } from '../stacked-avatar';
|
||||||
import { EventReaders } from '../event-readers';
|
import { EventReaders } from '../event-readers';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
import * as css from './ReadReceiptAvatars.css';
|
||||||
|
|
||||||
const MAX_DISPLAY = 5;
|
const MAX_DISPLAY = 5;
|
||||||
|
|
||||||
@@ -28,6 +40,7 @@ export function ReadReceiptAvatars({
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
const modalStyle = useModalStyle(360);
|
||||||
|
|
||||||
if (userIds.length === 0) return null;
|
if (userIds.length === 0) return null;
|
||||||
|
|
||||||
@@ -51,7 +64,7 @@ export function ReadReceiptAvatars({
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal variant="Surface" size="300">
|
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
@@ -62,28 +75,7 @@ export function ReadReceiptAvatars({
|
|||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
title={tooltipNames}
|
title={tooltipNames}
|
||||||
aria-label={tooltipNames}
|
aria-label={tooltipNames}
|
||||||
className="receipt-pill-btn"
|
className={css.ReceiptTrigger}
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: 0,
|
|
||||||
marginLeft: 'auto',
|
|
||||||
marginTop: '4px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '4px',
|
|
||||||
opacity: 0.85,
|
|
||||||
transition: 'opacity 0.15s, transform 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.opacity = '1';
|
|
||||||
e.currentTarget.style.transform = 'scale(1.04)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.opacity = '0.85';
|
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
||||||
<span
|
<span
|
||||||
@@ -93,10 +85,12 @@ export function ReadReceiptAvatars({
|
|||||||
backgroundColor: lotusTerminal
|
backgroundColor: lotusTerminal
|
||||||
? 'rgba(0,212,255,0.07)'
|
? 'rgba(0,212,255,0.07)'
|
||||||
: color.SurfaceVariant.Container,
|
: color.SurfaceVariant.Container,
|
||||||
border: lotusTerminal ? '1px solid rgba(0,212,255,0.30)' : '1px solid transparent',
|
border: lotusTerminal
|
||||||
|
? `${config.borderWidth.B300} solid rgba(0,212,255,0.30)`
|
||||||
|
: `${config.borderWidth.B300} solid transparent`,
|
||||||
boxShadow: lotusTerminal ? '0 0 10px rgba(0,212,255,0.12)' : 'none',
|
boxShadow: lotusTerminal ? '0 0 10px rgba(0,212,255,0.12)' : 'none',
|
||||||
borderRadius: '999px',
|
borderRadius: config.radii.Pill,
|
||||||
padding: '2px 6px',
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
gap: '0px',
|
gap: '0px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import parse from 'html-react-parser';
|
|||||||
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
|
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||||
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
||||||
@@ -17,6 +18,7 @@ export const RoomTopicViewer = as<
|
|||||||
}
|
}
|
||||||
>(({ name, topic, requestClose, className, ...props }, ref) => {
|
>(({ name, topic, requestClose, className, ...props }, ref) => {
|
||||||
const topicStr = typeof topic === 'string' ? topic : topic.topic;
|
const topicStr = typeof topic === 'string' ? topic : topic.topic;
|
||||||
|
const modalStyle = useModalStyle(480);
|
||||||
const isFormatted =
|
const isFormatted =
|
||||||
typeof topic !== 'string' &&
|
typeof topic !== 'string' &&
|
||||||
topic.format === 'org.matrix.custom.html' &&
|
topic.format === 'org.matrix.custom.html' &&
|
||||||
@@ -28,6 +30,7 @@ export const RoomTopicViewer = as<
|
|||||||
flexHeight
|
flexHeight
|
||||||
className={classNames(css.ModalFlex, className)}
|
className={classNames(css.ModalFlex, className)}
|
||||||
aria-labelledby="room-topic-title"
|
aria-labelledby="room-topic-title"
|
||||||
|
style={modalStyle}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { zIndices } from '../../styles/zIndex';
|
||||||
|
import { SeasonTheme } from './types';
|
||||||
|
import { getActiveSeason } from './seasonSchedule';
|
||||||
|
import { HalloweenOverlay } from './themes/Halloween';
|
||||||
|
import { ChristmasOverlay } from './themes/Christmas';
|
||||||
|
import { NewYearOverlay } from './themes/NewYear';
|
||||||
|
import { AutumnOverlay } from './themes/Autumn';
|
||||||
|
import { AprilFoolsOverlay } from './themes/AprilFools';
|
||||||
|
import { LunarNewYearOverlay } from './themes/LunarNewYear';
|
||||||
|
import { ValentinesOverlay } from './themes/Valentines';
|
||||||
|
import { StPatricksOverlay } from './themes/StPatricks';
|
||||||
|
import { EarthDayOverlay } from './themes/EarthDay';
|
||||||
|
import { DeepSpaceOverlay } from './themes/DeepSpace';
|
||||||
|
import { ArcadeOverlay } from './themes/Arcade';
|
||||||
|
|
||||||
|
// SeasonTheme + the date-window logic now live in leaf modules (single source
|
||||||
|
// of truth, shared with the settings UI). Re-exported here for existing
|
||||||
|
// importers that still reach for it from this file.
|
||||||
|
export type { SeasonTheme };
|
||||||
|
|
||||||
|
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
|
||||||
|
|
||||||
|
function buildOverlayContent(theme: SeasonTheme, reduced: boolean): React.ReactNode {
|
||||||
|
switch (theme) {
|
||||||
|
case 'halloween':
|
||||||
|
return <HalloweenOverlay reduced={reduced} />;
|
||||||
|
case 'christmas':
|
||||||
|
return <ChristmasOverlay reduced={reduced} />;
|
||||||
|
case 'newyear':
|
||||||
|
return <NewYearOverlay reduced={reduced} />;
|
||||||
|
case 'autumn':
|
||||||
|
return <AutumnOverlay reduced={reduced} />;
|
||||||
|
case 'aprilfools':
|
||||||
|
return <AprilFoolsOverlay reduced={reduced} />;
|
||||||
|
case 'lunar':
|
||||||
|
return <LunarNewYearOverlay reduced={reduced} />;
|
||||||
|
case 'valentines':
|
||||||
|
return <ValentinesOverlay reduced={reduced} />;
|
||||||
|
case 'stpatricks':
|
||||||
|
return <StPatricksOverlay reduced={reduced} />;
|
||||||
|
case 'earthday':
|
||||||
|
return <EarthDayOverlay reduced={reduced} />;
|
||||||
|
case 'deepspace':
|
||||||
|
return <DeepSpaceOverlay reduced={reduced} />;
|
||||||
|
case 'arcade':
|
||||||
|
return <ArcadeOverlay reduced={reduced} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Full-screen overlay (fixed position, used in App) ────────────────────────
|
||||||
|
|
||||||
|
function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
||||||
|
// by it, and below modals (9999) so dialogs are never obscured.
|
||||||
|
zIndex: zIndices.seasonalEffect,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{buildOverlayContent(theme, reduced)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Preview overlay (absolute position, contained in a card) ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the ambient (reduced-motion) version of a seasonal overlay inside
|
||||||
|
* a parent container. The parent must have `position: relative; overflow: hidden`.
|
||||||
|
*/
|
||||||
|
export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{buildOverlayContent(theme, true)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main exported component ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SeasonalEffect() {
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
const reduced =
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
const theme = useMemo<SeasonTheme | null>(() => {
|
||||||
|
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||||
|
if (override === 'off') return null;
|
||||||
|
if (override === 'auto') return getActiveSeason(new Date());
|
||||||
|
return override as SeasonTheme;
|
||||||
|
}, [settings.seasonalThemeOverride]);
|
||||||
|
|
||||||
|
if (!theme) return null;
|
||||||
|
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
||||||
|
// wastes GPU and looks cluttered. The settings UI enforces mutual exclusion on write;
|
||||||
|
// this guard covers any legacy state already persisted.
|
||||||
|
if (settings.chatBackground !== 'none') return null;
|
||||||
|
return <SeasonalOverlay theme={theme} reduced={reduced} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
|
||||||
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
|
// Date(year, monthIndex0, day)
|
||||||
|
const on = (monthIndex0: number, day: number): Date => new Date(2026, monthIndex0, day);
|
||||||
|
|
||||||
|
test('each theme activates on a representative day in its window', () => {
|
||||||
|
const cases: Array<[Date, SeasonTheme]> = [
|
||||||
|
[on(11, 31), 'newyear'], // Dec 31
|
||||||
|
[on(0, 1), 'newyear'], // Jan 1
|
||||||
|
[on(0, 25), 'lunar'], // Jan 25
|
||||||
|
[on(1, 3), 'lunar'], // Feb 3
|
||||||
|
[on(1, 12), 'valentines'], // Feb 12
|
||||||
|
[on(2, 16), 'stpatricks'], // Mar 16
|
||||||
|
[on(3, 1), 'aprilfools'], // Apr 1
|
||||||
|
[on(3, 21), 'earthday'], // Apr 21
|
||||||
|
[on(8, 12), 'arcade'], // Sep 12
|
||||||
|
[on(8, 25), 'autumn'], // Sep 25
|
||||||
|
[on(9, 20), 'halloween'], // Oct 20
|
||||||
|
[on(10, 1), 'halloween'], // Nov 1
|
||||||
|
[on(11, 15), 'christmas'], // Dec 15
|
||||||
|
];
|
||||||
|
for (const [date, expected] of cases) {
|
||||||
|
assert.equal(getActiveSeason(date), expected, `${date.toDateString()} -> ${expected}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('priority order resolves overlapping windows (Deep Space outranks Autumn)', () => {
|
||||||
|
// Oct 4-10 is inside Autumn's Oct<=14 window too; Deep Space comes first.
|
||||||
|
assert.equal(getActiveSeason(on(9, 5)), 'deepspace'); // Oct 5
|
||||||
|
// Oct 12 is past Deep Space -> falls through to Autumn.
|
||||||
|
assert.equal(getActiveSeason(on(9, 12)), 'autumn');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('New Year outranks Lunar New Year on Jan 1-2', () => {
|
||||||
|
assert.equal(getActiveSeason(on(0, 1)), 'newyear');
|
||||||
|
// Jan 22+ is past New Year -> Lunar.
|
||||||
|
assert.equal(getActiveSeason(on(0, 22)), 'lunar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null on an off-season day', () => {
|
||||||
|
assert.equal(getActiveSeason(on(5, 15)), null); // Jun 15
|
||||||
|
assert.equal(getActiveSeason(on(6, 4)), null); // Jul 4
|
||||||
|
});
|
||||||
|
|
||||||
|
test('window boundaries are inclusive at both ends', () => {
|
||||||
|
assert.equal(getActiveSeason(on(1, 10)), 'valentines'); // Feb 10 start
|
||||||
|
assert.equal(getActiveSeason(on(1, 15)), 'valentines'); // Feb 15 end
|
||||||
|
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||||
|
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||||
|
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||||
|
assert.equal(new Set(themes).size, 11); // unique
|
||||||
|
for (const t of themes) {
|
||||||
|
assert.ok(
|
||||||
|
typeof SEASON_DATE_RANGES[t] === 'string' && SEASON_DATE_RANGES[t].length > 0,
|
||||||
|
`missing date range for ${t}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for when each seasonal theme auto-activates.
|
||||||
|
*
|
||||||
|
* Both `getActiveSeason` (the runtime "Auto" selector) and the settings UI read
|
||||||
|
* this list, so the date windows shown to the user can never drift from the
|
||||||
|
* dates actually used. Order matters: it is the activation PRIORITY — the first
|
||||||
|
* entry whose window matches wins (e.g. Deep Space outranks Autumn in their
|
||||||
|
* early-October overlap).
|
||||||
|
*/
|
||||||
|
export type SeasonScheduleEntry = {
|
||||||
|
theme: SeasonTheme;
|
||||||
|
/** Human-readable activation window for display in settings. */
|
||||||
|
dateRange: string;
|
||||||
|
/** Whether this theme is active on the given month (1-12) and day (1-31). */
|
||||||
|
matches: (month: number, day: number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEASON_SCHEDULE: SeasonScheduleEntry[] = [
|
||||||
|
{
|
||||||
|
theme: 'newyear',
|
||||||
|
dateRange: 'Dec 31 – Jan 2',
|
||||||
|
matches: (m, d) => (m === 12 && d === 31) || (m === 1 && d <= 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'valentines',
|
||||||
|
dateRange: 'Feb 10 – 15',
|
||||||
|
matches: (m, d) => m === 2 && d >= 10 && d <= 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'stpatricks',
|
||||||
|
dateRange: 'Mar 15 – 18',
|
||||||
|
matches: (m, d) => m === 3 && d >= 15 && d <= 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'aprilfools',
|
||||||
|
dateRange: 'Apr 1',
|
||||||
|
matches: (m, d) => m === 4 && d === 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'earthday',
|
||||||
|
dateRange: 'Apr 20 – 23',
|
||||||
|
matches: (m, d) => m === 4 && d >= 20 && d <= 23,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'lunar',
|
||||||
|
dateRange: 'Jan 22 – Feb 5',
|
||||||
|
matches: (m, d) => (m === 1 && d >= 22) || (m === 2 && d <= 5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'arcade',
|
||||||
|
dateRange: 'Sep 12',
|
||||||
|
matches: (m, d) => m === 9 && d === 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'deepspace',
|
||||||
|
dateRange: 'Oct 4 – 10',
|
||||||
|
matches: (m, d) => m === 10 && d >= 4 && d <= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'halloween',
|
||||||
|
dateRange: 'Oct 15 – Nov 1',
|
||||||
|
matches: (m, d) => (m === 10 && d >= 15) || (m === 11 && d === 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'christmas',
|
||||||
|
dateRange: 'Dec 10 – 30',
|
||||||
|
matches: (m, d) => m === 12 && d >= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'autumn',
|
||||||
|
dateRange: 'Sep 21 – Oct 14',
|
||||||
|
matches: (m, d) => (m === 9 && d >= 21) || (m === 10 && d <= 14),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Map of theme → human-readable activation window (for settings captions). */
|
||||||
|
export const SEASON_DATE_RANGES: Record<SeasonTheme, string> = SEASON_SCHEDULE.reduce(
|
||||||
|
(acc, entry) => {
|
||||||
|
acc[entry.theme] = entry.dateRange;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<SeasonTheme, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seasonal theme that should be active on `now`, or null if none. First
|
||||||
|
* matching entry in SEASON_SCHEDULE priority order wins.
|
||||||
|
*/
|
||||||
|
export function getActiveSeason(now: Date): SeasonTheme | null {
|
||||||
|
const month = now.getMonth() + 1; // 1-12
|
||||||
|
const day = now.getDate();
|
||||||
|
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doodle float-up — a hand-drawn glyph drifts gently upward while bobbing
|
||||||
|
* side to side and lazily rotating, like a thought balloon escaping the page.
|
||||||
|
* GPU-only: transform + opacity exclusively. A tall translateY lets one set of
|
||||||
|
* keyframes serve every doodle; per-element duration/delay/scale add variety.
|
||||||
|
*/
|
||||||
|
export const animDoodleFloat = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 8vh, 0) rotate(-8deg) scale(0.85)', opacity: '0' },
|
||||||
|
'10%': { opacity: '1' },
|
||||||
|
'35%': { transform: 'translate3d(16px, -28vh, 0) rotate(6deg) scale(1)' },
|
||||||
|
'65%': { transform: 'translate3d(-14px, -64vh, 0) rotate(-5deg) scale(1.04)' },
|
||||||
|
'90%': { opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(10px, -112vh, 0) rotate(7deg) scale(1.1)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confetti tumble — a small chip falls while flipping. Reuses a single tall
|
||||||
|
* translateY; the flip (rotate + scaleX) sells the paper tumble cheaply.
|
||||||
|
*/
|
||||||
|
export const animConfettiTumble = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg) scaleX(1)', opacity: '0' },
|
||||||
|
'8%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(18px, 50vh, 0) rotate(220deg) scaleX(-1)' },
|
||||||
|
'92%': { opacity: '0.9' },
|
||||||
|
'100%': { transform: 'translate3d(-12px, 112vh, 0) rotate(440deg) scaleX(1)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playful wobble — an almost-imperceptible skew/rotate of a faux tint layer so
|
||||||
|
* the whole scene feels gently "tickled". Tiny amplitude keeps it from being
|
||||||
|
* disorienting. Transform only, stays on the compositor.
|
||||||
|
*/
|
||||||
|
export const animWobble = keyframes({
|
||||||
|
'0%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
|
||||||
|
'50%': { transform: 'rotate(0.5deg) skewX(0.4deg) scale(1.01)' },
|
||||||
|
'100%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pastel aurora drift — a soft rainbow wash high in the scene slides and
|
||||||
|
* breathes. translateX + opacity (never background-position) to stay on GPU.
|
||||||
|
*/
|
||||||
|
export const animRainbowDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'translate3d(5%, 0, 0) scaleY(1.06)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Googly-eye look-around — the pupil layer nudges around its socket, giving
|
||||||
|
* each eye a cheeky wandering gaze. Small translate only.
|
||||||
|
*/
|
||||||
|
export const animGoogly = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(1.5px, 1px, 0)' },
|
||||||
|
'20%': { transform: 'translate3d(-1.5px, 1.5px, 0)' },
|
||||||
|
'45%': { transform: 'translate3d(1px, -1.5px, 0)' },
|
||||||
|
'70%': { transform: 'translate3d(-1px, -0.5px, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(1.5px, 1px, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sly wink/sparkle — a four-point glint that twinkles open and shut, scaling
|
||||||
|
* and fading like a sly little wink. Transform + opacity only.
|
||||||
|
*/
|
||||||
|
export const animSparkle = keyframes({
|
||||||
|
'0%, 100%': { transform: 'scale(0.2) rotate(0deg)', opacity: '0' },
|
||||||
|
'40%': { transform: 'scale(1) rotate(35deg)', opacity: '0.9' },
|
||||||
|
'60%': { transform: 'scale(0.95) rotate(45deg)', opacity: '0.7' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animDoodleFloat,
|
||||||
|
animConfettiTumble,
|
||||||
|
animWobble,
|
||||||
|
animRainbowDrift,
|
||||||
|
animGoogly,
|
||||||
|
animSparkle,
|
||||||
|
} from './AprilFools.css';
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the scene is identical on every mount and the
|
||||||
|
// reduced-motion preview thumbnail is stable. Large primes spread the values.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bright-but-soft pastel rainbow in oklch. Kept luminous and gentle so the
|
||||||
|
// doodles read as crayon pastel over chat without ever fighting the text.
|
||||||
|
const PASTELS = [
|
||||||
|
'oklch(0.85 0.12 20)', // pink
|
||||||
|
'oklch(0.88 0.12 90)', // butter yellow
|
||||||
|
'oklch(0.82 0.12 160)', // mint
|
||||||
|
'oklch(0.8 0.12 260)', // periwinkle
|
||||||
|
'oklch(0.84 0.12 320)', // lilac
|
||||||
|
'oklch(0.86 0.11 50)', // peach
|
||||||
|
];
|
||||||
|
|
||||||
|
// Inline-SVG data-URI doodle glyphs, drawn hand-sketch style (round caps,
|
||||||
|
// open paths). `enc()` keeps them CSP-safe — no external assets, no base64.
|
||||||
|
const enc = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
|
||||||
|
// A single rough stroke wrapper helper for the glyph SVGs.
|
||||||
|
const stroke = (color: string, body: string) =>
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' ` +
|
||||||
|
`stroke='${color}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'>${body}</svg>`;
|
||||||
|
|
||||||
|
// Question mark — the playful "huh?" centerpiece doodle.
|
||||||
|
const glyphQuestion = (c: string) =>
|
||||||
|
stroke(
|
||||||
|
c,
|
||||||
|
`<path d='M11 11 q0 -6 6 -6 q6 0 6 5 q0 4 -5 6 q-2 1 -2 4'/>` +
|
||||||
|
`<circle cx='16' cy='27' r='0.6' fill='${c}'/>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exclamation / "bang" — a surprised little doodle.
|
||||||
|
const glyphBang = (c: string) =>
|
||||||
|
stroke(c, `<path d='M16 5 L16 20'/><circle cx='16' cy='27' r='0.6' fill='${c}'/>`);
|
||||||
|
|
||||||
|
// Squiggle — a loopy scribble that adds whimsy.
|
||||||
|
const glyphSquiggle = (c: string) => stroke(c, `<path d='M5 18 q4 -10 8 0 t8 0 t8 0'/>`);
|
||||||
|
|
||||||
|
// Five-point doodle star (open-stroke, hand-drawn look).
|
||||||
|
const glyphStar = (c: string) =>
|
||||||
|
stroke(
|
||||||
|
c,
|
||||||
|
`<path d='M16 5 L19.4 13 L28 13.6 L21.4 19.2 L23.5 27.6 L16 22.8 L8.5 27.6 ` +
|
||||||
|
`L10.6 19.2 L4 13.6 L12.6 13 Z'/>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A tiny heart doodle for extra grin.
|
||||||
|
const glyphHeart = (c: string) =>
|
||||||
|
stroke(c, `<path d='M16 26 C6 18 7 8 16 12 C25 8 26 18 16 26 Z'/>`);
|
||||||
|
|
||||||
|
const GLYPHS = [glyphQuestion, glyphBang, glyphSquiggle, glyphStar, glyphHeart, glyphQuestion];
|
||||||
|
|
||||||
|
type Doodle = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
glyph: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
startTop: number; // used for the static (reduced) scatter
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Confetti = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
startTop: number;
|
||||||
|
ratio: number; // chip aspect
|
||||||
|
round: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Eye = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Spark = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AprilFoolsOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// ~16 drifting doodles. Built once; per-element timing creates the variety.
|
||||||
|
const doodles = useMemo<Doodle[]>(() => {
|
||||||
|
const count = 16;
|
||||||
|
const out: Doodle[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const color = PASTELS[i % PASTELS.length];
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 0.1) * 96 + 2,
|
||||||
|
size: 18 + rand(i + 0.3) * 22,
|
||||||
|
glyph: enc(GLYPHS[i % GLYPHS.length](color)),
|
||||||
|
duration: 16 + rand(i + 0.5) * 12,
|
||||||
|
delay: -rand(i + 0.7) * 26,
|
||||||
|
startTop: rand(i + 0.9) * 92 + 4,
|
||||||
|
opacity: 0.5 + rand(i + 0.2) * 0.32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ~14 confetti chips in a couple of falling bands.
|
||||||
|
const confetti = useMemo<Confetti[]>(() => {
|
||||||
|
const count = 14;
|
||||||
|
const out: Confetti[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 3.1) * 98 + 1,
|
||||||
|
size: 5 + rand(i + 3.3) * 6,
|
||||||
|
color: PASTELS[(i + 2) % PASTELS.length],
|
||||||
|
duration: 10 + rand(i + 3.5) * 9,
|
||||||
|
delay: -rand(i + 3.7) * 18,
|
||||||
|
startTop: rand(i + 3.9) * 96 + 2,
|
||||||
|
ratio: 0.45 + rand(i + 3.2) * 0.8,
|
||||||
|
round: rand(i + 3.6) > 0.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A few googly eyes peeking from corners/edges — the cheeky surprise.
|
||||||
|
const eyes = useMemo<Eye[]>(() => {
|
||||||
|
const anchors = [
|
||||||
|
{ left: 6, top: 12 },
|
||||||
|
{ left: 90, top: 20 },
|
||||||
|
{ left: 80, top: 82 },
|
||||||
|
{ left: 14, top: 74 },
|
||||||
|
];
|
||||||
|
return anchors.map((a, i) => ({
|
||||||
|
left: a.left,
|
||||||
|
top: a.top,
|
||||||
|
size: 22 + rand(i + 5.1) * 12,
|
||||||
|
duration: 3 + rand(i + 5.3) * 2.5,
|
||||||
|
delay: -rand(i + 5.5) * 3,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sly winking sparkles scattered sparsely.
|
||||||
|
const sparks = useMemo<Spark[]>(() => {
|
||||||
|
const count = 5;
|
||||||
|
const out: Spark[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 7.1) * 90 + 5,
|
||||||
|
top: rand(i + 7.3) * 84 + 8,
|
||||||
|
size: 12 + rand(i + 7.5) * 12,
|
||||||
|
color: PASTELS[(i + 1) % PASTELS.length],
|
||||||
|
duration: 4 + rand(i + 7.7) * 3,
|
||||||
|
delay: -rand(i + 7.9) * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Four-point glint used for the winking sparkles.
|
||||||
|
const sparkGlint = (c: string) =>
|
||||||
|
enc(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>` +
|
||||||
|
`<path d='M12 0 C13 8 16 11 24 12 C16 13 13 16 12 24 C11 16 8 13 0 12 C8 11 11 8 12 0 Z' fill='${c}'/></svg>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Soft pastel ambient wash — layered oklch radials for depth. Very low
|
||||||
|
opacity so chat text keeps WCAG-AA contrast. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(110% 70% at 18% -8%, oklch(0.85 0.12 20 / 0.1) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(95% 65% at 86% 0%, oklch(0.82 0.12 160 / 0.09) 0%, transparent 58%)',
|
||||||
|
'radial-gradient(120% 80% at 50% 112%, oklch(0.8 0.12 260 / 0.1) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.88 0.12 90 / 0.05) 0%, transparent 30%, transparent 78%, oklch(0.84 0.12 320 / 0.06) 100%)',
|
||||||
|
].join(','),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Faux wobble layer — a near-invisible pastel haze that gently skews so
|
||||||
|
the whole scene feels playfully "tickled". Tiny amplitude = not
|
||||||
|
nauseating. backdrop-filter is one cheap layer for a candy bloom. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '-2%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||||
|
WebkitBackdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(130% 120% at 50% 45%, transparent 60%, oklch(0.86 0.11 50 / 0.05) 80%, oklch(0.8 0.12 260 / 0.08) 100%)',
|
||||||
|
transformOrigin: '50% 50%',
|
||||||
|
animation: reduced ? 'none' : `${animWobble} 14s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pastel rainbow aurora high up — soft band of the full palette. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8%',
|
||||||
|
left: '-10%',
|
||||||
|
right: '-10%',
|
||||||
|
height: '42%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
filter: 'blur(30px)',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(50% 100% at 18% 0%, oklch(0.85 0.12 20 / 0.16) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 40% 0%, oklch(0.88 0.12 90 / 0.14) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 62% 0%, oklch(0.82 0.12 160 / 0.14) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 84% 0%, oklch(0.8 0.12 260 / 0.16) 0%, transparent 72%)',
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animRainbowDrift} 20s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drifting doodles. Motion: rise from below. Reduced: static scatter. */}
|
||||||
|
{doodles.map((d, i) => {
|
||||||
|
const common: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${d.left}%`,
|
||||||
|
width: `${d.size}px`,
|
||||||
|
height: `${d.size}px`,
|
||||||
|
backgroundImage: d.glyph,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
opacity: d.opacity,
|
||||||
|
filter: 'drop-shadow(0 1px 1px oklch(0.4 0.05 300 / 0.18))',
|
||||||
|
};
|
||||||
|
if (reduced) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`doodle-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: `${d.startTop}%`,
|
||||||
|
transform: `rotate(${(rand(i + 11) - 0.5) * 24}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`doodle-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: 0,
|
||||||
|
animation: `${animDoodleFloat} ${d.duration}s ease-in-out ${d.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Light confetti — tumbling pastel chips. */}
|
||||||
|
{confetti.map((c, i) => {
|
||||||
|
const common: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${c.left}%`,
|
||||||
|
width: `${c.size}px`,
|
||||||
|
height: `${c.size * c.ratio}px`,
|
||||||
|
background: c.color,
|
||||||
|
borderRadius: c.round ? '50%' : '1px',
|
||||||
|
opacity: 0.75,
|
||||||
|
};
|
||||||
|
if (reduced) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`confetti-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: `${c.startTop}%`,
|
||||||
|
transform: `rotate(${rand(i + 13) * 360}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`confetti-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: 0,
|
||||||
|
animation: `${animConfettiTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Googly eyes peeking from the edges — pupil wanders cheekily. */}
|
||||||
|
{eyes.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={`eye-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${e.left}%`,
|
||||||
|
top: `${e.top}%`,
|
||||||
|
width: `${e.size}px`,
|
||||||
|
height: `${e.size}px`,
|
||||||
|
marginLeft: `${-e.size / 2}px`,
|
||||||
|
marginTop: `${-e.size / 2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 38% 32%, oklch(0.99 0.005 90 / 0.85) 0%, oklch(0.95 0.01 90 / 0.7) 62%, oklch(0.75 0.02 90 / 0.6) 100%)',
|
||||||
|
boxShadow: 'inset 0 0 0 1.5px oklch(0.45 0.03 300 / 0.35)',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Pupil */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
width: `${e.size * 0.4}px`,
|
||||||
|
height: `${e.size * 0.4}px`,
|
||||||
|
marginLeft: `${-e.size * 0.2}px`,
|
||||||
|
marginTop: `${-e.size * 0.2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 36% 30%, oklch(0.5 0.04 300 / 0.95) 0%, oklch(0.28 0.04 300 / 0.95) 70%)',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animGoogly} ${e.duration}s ease-in-out ${e.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Catchlight */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '22%',
|
||||||
|
top: '20%',
|
||||||
|
width: '28%',
|
||||||
|
height: '28%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'oklch(0.99 0.005 90 / 0.85)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Sly winking sparkles. Static (reduced) shows them mid-glint. */}
|
||||||
|
{sparks.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`spark-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
top: `${s.top}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
backgroundImage: sparkGlint(s.color),
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: `drop-shadow(0 0 3px ${s.color.replace(')', ' / 0.5)')})`,
|
||||||
|
opacity: reduced ? 0.8 : undefined,
|
||||||
|
transform: reduced ? 'scale(0.95) rotate(40deg)' : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||