Compare commits
1853 Commits
71386f4ef2
..
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| ca9abb5363 | |||
| 21276a47fc | |||
| b7788cc79c | |||
| 13d08c3fd7 | |||
| a899d7d3a8 | |||
| dcd8201e16 | |||
| 41149db685 | |||
| 668bdaad7d | |||
| ee6bdd8241 | |||
| 0bbdd7ce94 | |||
| 7c85ad177f | |||
| bbf0800c19 | |||
| abd0753148 | |||
| 8192da5a12 | |||
| 6dc478e989 | |||
| 049472e25f | |||
| 81904372bc | |||
| c82ab5c7f5 | |||
| ebcd8ec926 | |||
| 4ff07ea2bd | |||
| 804caa5130 | |||
| 625f0c2386 | |||
| 4d7a05c0f1 | |||
| b5e7bcc0b8 | |||
| bca371ad38 | |||
| 899a14c119 | |||
| 6728a1274d | |||
| 21dda93d1b | |||
| 4380041014 | |||
| 8729ccfcf5 | |||
| 8ab1ec254b | |||
| 23f715857c | |||
| f589182709 | |||
| ef573376ac | |||
| 34d9272790 | |||
| 96f7187031 | |||
| 664dcd4cd8 | |||
| 7f960b026b | |||
| 992d2b83b3 | |||
| a9505ca5b2 | |||
| dca51a41ef | |||
| 579449acc3 | |||
| 34592d9144 | |||
| 0adce52d37 | |||
| 501d493ca4 | |||
| ffb934fce6 | |||
| 440c1fe948 | |||
| aa62df9c75 | |||
| 15ac538a4b | |||
| 39cfc23ebe | |||
| 7a8cadc6ec | |||
| 91bd360125 | |||
| 7da960ac8c | |||
| ed51c39fe7 | |||
| 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,728 @@
|
|||||||
|
# Lotus Chat — Manual Testing Guide
|
||||||
|
|
||||||
|
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
|
||||||
|
**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** (see the outstanding-verification backlog below / `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
|
||||||
|
|
||||||
|
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the outstanding-verification backlog below (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
|
||||||
|
|
||||||
|
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
|
||||||
|
|
||||||
|
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
|
||||||
|
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
|
||||||
|
3. Reply to a reply _inside_ the panel.
|
||||||
|
|
||||||
|
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
|
||||||
|
|
||||||
|
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
|
||||||
|
|
||||||
|
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
|
||||||
|
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
|
||||||
|
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
|
||||||
|
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
|
||||||
|
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
|
||||||
|
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
|
||||||
|
|
||||||
|
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
|
||||||
|
|
||||||
|
### O3. Math / LaTeX (P4-4)
|
||||||
|
|
||||||
|
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
|
||||||
|
|
||||||
|
### O4. Encrypted search cache (P4-8) — opt-in
|
||||||
|
|
||||||
|
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
|
||||||
|
|
||||||
|
### O5. Session hardening (N97a) — cross-tab
|
||||||
|
|
||||||
|
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
|
||||||
|
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
|
||||||
|
|
||||||
|
### O6. Audit-wave correctness fixes (AW-1)
|
||||||
|
|
||||||
|
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
|
||||||
|
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
|
||||||
|
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
|
||||||
|
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
|
||||||
|
|
||||||
|
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
|
||||||
|
|
||||||
|
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
|
||||||
|
|
||||||
|
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
|
||||||
|
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
|
||||||
|
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
|
||||||
|
|
||||||
|
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
|
||||||
|
|
||||||
|
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P. Accessibility (P3-4) — needs a browser + a screen reader
|
||||||
|
|
||||||
|
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
|
||||||
|
|
||||||
|
### P1. Keyboard-only golden path (no mouse)
|
||||||
|
|
||||||
|
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
|
||||||
|
|
||||||
|
### P2. `?` shortcuts dialog
|
||||||
|
|
||||||
|
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
|
||||||
|
|
||||||
|
### P3. Screen-reader: reading messages
|
||||||
|
|
||||||
|
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
|
||||||
|
|
||||||
|
### P4. Screen-reader: live announcements
|
||||||
|
|
||||||
|
- **New message** arrives while you're reading → announced (polite).
|
||||||
|
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
|
||||||
|
- **Editing a message** → the edit box announces "Editing message from X".
|
||||||
|
|
||||||
|
### P5. Focus return from dialogs
|
||||||
|
|
||||||
|
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
|
||||||
|
|
||||||
|
### P6. axe / Lighthouse scan
|
||||||
|
|
||||||
|
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority if you're short on time
|
||||||
|
|
||||||
|
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
|
||||||
|
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
|
||||||
|
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
|
||||||
|
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
|
||||||
|
5. **D** (EC control sweep) — guards against the fork breaking calls.
|
||||||
|
6. Everything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outstanding verification backlog
|
||||||
|
|
||||||
|
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
|
||||||
|
|
||||||
|
**Invite QR is now generated LOCALLY (2026-07):** Room settings → Share Room → the QR code renders (a black-on-white SVG in a white box) with **no network request** to `api.qrserver.com` (check DevTools Network — there should be no external QR fetch, and it should work offline / behind strict CSP). **Scan it** with a phone camera / Matrix app → it opens the correct `matrix.to` room-invite link. (`api.qrserver.com` was removed from the prod CSP img-src, so a regression would make the QR blank rather than silently phone home.)
|
||||||
|
|
||||||
|
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
|
||||||
|
|
||||||
|
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
|
||||||
|
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
|
||||||
|
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
|
||||||
|
|
||||||
|
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
|
||||||
|
|
||||||
|
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
|
||||||
|
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
||||||
|
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
||||||
|
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
||||||
|
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
||||||
|
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
||||||
|
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
|
||||||
|
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
||||||
|
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
||||||
|
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
||||||
|
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
|
||||||
|
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
|
||||||
|
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
|
||||||
|
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -1,280 +1,204 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
|
||||||
|
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
|
||||||
|
- 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
|
||||||
|
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
|
||||||
|
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
|
||||||
|
- 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 fork's `io.lotus.*` action catalog + the publish procedure are in
|
||||||
|
**[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||||
cinny.domain.tld {
|
cinny.domain.tld {
|
||||||
root * /path/to/cinny/dist
|
root * /path/to/cinny/dist
|
||||||
try_files {path} / index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
|
# Security headers (generic; add a Content-Security-Policy suited to your
|
||||||
|
# homeserver + any embedded services). Caddy serves HTTPS automatically, so
|
||||||
|
# HSTS is delivered over TLS.
|
||||||
|
header {
|
||||||
|
X-Frame-Options SAMEORIGIN
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||||
|
Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ server {
|
|||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl;
|
||||||
server_name cinny.domain.tld;
|
server_name cinny.domain.tld;
|
||||||
|
|
||||||
|
# Security headers (generic; add a Content-Security-Policy suited to your
|
||||||
|
# homeserver + any embedded services). NOTE: nginx does not inherit
|
||||||
|
# server-level add_header into a location that sets its own add_header.
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /opt/cinny/dist/;
|
root /opt/cinny/dist/;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -25,7 +25,7 @@ export default [
|
|||||||
tsPlugin.configs['flat/eslint-recommended'],
|
tsPlugin.configs['flat/eslint-recommended'],
|
||||||
...tsPlugin.configs['flat/recommended'],
|
...tsPlugin.configs['flat/recommended'],
|
||||||
reactPlugin.configs.flat.recommended,
|
reactPlugin.configs.flat.recommended,
|
||||||
reactHooksPlugin.configs.flat['recommended'],
|
reactHooksPlugin.configs.flat.recommended,
|
||||||
// Register jsx-a11y plugin (rules selectively enabled below)
|
// Register jsx-a11y plugin (rules selectively enabled below)
|
||||||
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
||||||
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
|
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
|
||||||
@@ -115,6 +115,26 @@ export default [
|
|||||||
'jsx-a11y/media-has-caption': 'off',
|
'jsx-a11y/media-has-caption': 'off',
|
||||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||||
'jsx-a11y/alt-text': 'off',
|
'jsx-a11y/alt-text': 'off',
|
||||||
|
// A11y regression gate (P3-4). A CURATED set — correctness rules that catch
|
||||||
|
// real WCAG gaps (missing accessible names, malformed ARIA) without
|
||||||
|
// flooding on the pre-existing clickable-div patterns. The heavier
|
||||||
|
// interaction rules (no-static-element-interactions,
|
||||||
|
// click-events-have-key-events) are a separate cleanup and stay OFF.
|
||||||
|
'jsx-a11y/aria-props': 'error',
|
||||||
|
'jsx-a11y/aria-proptypes': 'error',
|
||||||
|
'jsx-a11y/aria-role': ['error', { ignoreNonDOM: true }],
|
||||||
|
'jsx-a11y/aria-unsupported-elements': 'error',
|
||||||
|
'jsx-a11y/role-has-required-aria-props': 'error',
|
||||||
|
'jsx-a11y/role-supports-aria-props': 'error',
|
||||||
|
'jsx-a11y/no-redundant-roles': 'error',
|
||||||
|
'jsx-a11y/anchor-has-content': 'error',
|
||||||
|
'jsx-a11y/heading-has-content': 'error',
|
||||||
|
'jsx-a11y/label-has-associated-control': ['error', { assert: 'either', depth: 5 }],
|
||||||
|
// NOT enabled: control-has-associated-label. This repo labels most inputs
|
||||||
|
// with folds `<Text as="label" htmlFor>` — a component the rule's static
|
||||||
|
// analysis can't see as a <label>, producing false positives on correctly
|
||||||
|
// labeled controls. The genuinely-unlabeled controls it surfaced (sliders,
|
||||||
|
// file input, media players, notes) were fixed directly with aria-label.
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,4 +143,17 @@ export default [
|
|||||||
'no-undef': 'off',
|
'no-undef': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Test files commonly define several small mock/fake classes and named
|
||||||
|
// function expressions used as constructor mocks (e.g.
|
||||||
|
// `setGlobal('AudioWorkletNode', function AudioWorkletNode(){})`), which must
|
||||||
|
// NOT be rewritten to arrows (arrows aren't constructable). Relax the
|
||||||
|
// stylistic class/callback rules here.
|
||||||
|
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||||
|
rules: {
|
||||||
|
'max-classes-per-file': 'off',
|
||||||
|
'lines-between-class-members': 'off',
|
||||||
|
'prefer-arrow-callback': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,11 @@
|
|||||||
"@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",
|
"@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,8 +33,8 @@
|
|||||||
"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",
|
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
"emojibase-data": "17.0.0",
|
"emojibase-data": "17.0.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
@@ -49,14 +49,15 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"katex": "0.16.11",
|
||||||
"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.7.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",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-aria": "3.48.0",
|
"react-aria": "3.48.0",
|
||||||
"react-blurhash": "0.3.0",
|
"react-blurhash": "0.3.0",
|
||||||
@@ -73,16 +74,17 @@
|
|||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.124.2",
|
"slate-react": "0.124.2",
|
||||||
"styled-components": "6.4.2",
|
"styled-components": "6.4.2",
|
||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10",
|
||||||
|
"workbox-precaching": "7.4.1"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
@@ -108,6 +110,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 +1791,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,10 +2690,16 @@
|
|||||||
"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.1",
|
||||||
"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.1.tgz",
|
||||||
"integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==",
|
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -3774,402 +3777,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",
|
||||||
@@ -4308,16 +3920,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/dompurify": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
|
||||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dompurify": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -4364,6 +3966,13 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/katex": {
|
||||||
|
"version": "0.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||||
|
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.1",
|
"version": "25.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
@@ -4432,7 +4041,7 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"devOptional": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/ua-parser-js": {
|
"node_modules/@types/ua-parser-js": {
|
||||||
"version": "0.7.39",
|
"version": "0.7.39",
|
||||||
@@ -4849,6 +4458,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 +4494,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",
|
||||||
@@ -5934,12 +5540,16 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/content-type": {
|
"node_modules/content-type": {
|
||||||
"version": "1.0.5",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/conventional-commit-types": {
|
"node_modules/conventional-commit-types": {
|
||||||
@@ -6382,6 +5992,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",
|
||||||
@@ -6568,15 +6190,6 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
|
||||||
"version": "3.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
|
||||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@types/trusted-types": "^2.0.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/domutils": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
@@ -6605,19 +6218,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 +7212,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 +8044,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",
|
||||||
@@ -9488,6 +9081,31 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/katex": {
|
||||||
|
"version": "0.16.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
|
||||||
|
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://opencollective.com/katex",
|
||||||
|
"https://github.com/sponsors/katex"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^8.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"katex": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/katex/node_modules/commander": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -10338,16 +9956,16 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk": {
|
"node_modules/matrix-js-sdk": {
|
||||||
"version": "41.6.0-rc.0",
|
"version": "41.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.6.0-rc.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
|
||||||
"integrity": "sha512-FcTQyR+Nfh0ASEogYcX393hxGr1936Esg53Z+0f9O4SBsAxl1ZSkLXY3JfLZRLX9dNe38VVwQDQE6QuwnwV7Zw==",
|
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0",
|
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^1.0.4",
|
"content-type": "^2.0.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"matrix-events-sdk": "0.0.1",
|
"matrix-events-sdk": "0.0.1",
|
||||||
@@ -10564,26 +10182,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 +10741,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 +10751,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",
|
||||||
@@ -11178,6 +10759,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode.react": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/raf-schd": {
|
"node_modules/raf-schd": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
@@ -12748,12 +12338,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 +12386,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 +12904,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",
|
||||||
@@ -13635,7 +13222,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
||||||
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/workbox-expiration": {
|
"node_modules/workbox-expiration": {
|
||||||
@@ -13676,7 +13262,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
||||||
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1",
|
"workbox-core": "7.4.1",
|
||||||
@@ -13713,7 +13298,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
||||||
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1"
|
"workbox-core": "7.4.1"
|
||||||
@@ -13723,7 +13307,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
||||||
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1"
|
"workbox-core": "7.4.1"
|
||||||
|
|||||||
@@ -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,11 @@
|
|||||||
"@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",
|
"@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,8 +58,8 @@
|
|||||||
"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",
|
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
"emojibase-data": "17.0.0",
|
"emojibase-data": "17.0.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
@@ -72,14 +74,15 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"katex": "0.16.11",
|
||||||
"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.7.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",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-aria": "3.48.0",
|
"react-aria": "3.48.0",
|
||||||
"react-blurhash": "0.3.0",
|
"react-blurhash": "0.3.0",
|
||||||
@@ -96,16 +99,17 @@
|
|||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.124.2",
|
"slate-react": "0.124.2",
|
||||||
"styled-components": "6.4.2",
|
"styled-components": "6.4.2",
|
||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10",
|
||||||
|
"workbox-precaching": "7.4.1"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
@@ -131,6 +135,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');
|
||||||
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
|
|||||||
<Text size="L400">Account Data</Text>
|
<Text size="L400">Account Data</Text>
|
||||||
<Input
|
<Input
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
aria-label="Account data type"
|
||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
readOnly
|
readOnly
|
||||||
|
|||||||
@@ -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,12 @@ 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 { useReducedMotion } from '../hooks/useReducedMotion';
|
||||||
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 +67,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 +109,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 +131,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,7 +148,6 @@ 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
|
||||||
@@ -188,11 +183,11 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100" alignItems="Center">
|
||||||
<Text size="H3" align="Center" truncate>
|
<Text size="H3" align="Center" truncate>
|
||||||
{roomName}
|
{roomName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T300">
|
<Text size="T300" align="Center">
|
||||||
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
{info.intent === 'video' ? 'Incoming Video Call' : 'Incoming Voice Call'}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -254,10 +249,154 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
<audio ref={audioRef} loop style={{ display: 'none' }}>
|
);
|
||||||
<source src={CallSound} type="audio/ogg" />
|
}
|
||||||
</audio>
|
|
||||||
</>
|
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,16 +408,28 @@ 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;
|
||||||
const startCall = useCallStart(dm);
|
const startCall = useCallStart(dm);
|
||||||
|
|
||||||
|
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
|
||||||
|
// against the component unmounting during that await.
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||||
async (event, room, toStartOfTimeline, removed, data) => {
|
async (event, room, toStartOfTimeline, removed, data) => {
|
||||||
// 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 +438,34 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
await event.getDecryptionPromise();
|
await event.getDecryptionPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// C-L6: bail if we unmounted while awaiting decryption above.
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
|
// 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 +503,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 +528,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
|
|
||||||
setCallInfo(info);
|
setCallInfo(info);
|
||||||
},
|
},
|
||||||
[mx, directs],
|
[mx, directs, callEmbed, setToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -388,10 +564,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 +590,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 +610,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);
|
||||||
return (
|
const allRemoteMuted = useRemoteAllMuted(callEmbed);
|
||||||
<div
|
|
||||||
aria-label="Microphone muted"
|
const localMicMuted = !controlState.microphone;
|
||||||
style={{
|
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',
|
position: 'absolute',
|
||||||
bottom: '8px',
|
|
||||||
left: '8px',
|
|
||||||
zIndex: 3,
|
zIndex: 3,
|
||||||
background: 'rgba(0,0,0,0.60)',
|
background: 'rgba(0,0,0,0.65)',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
borderRadius: '6px',
|
borderRadius: config.radii.R300,
|
||||||
padding: '3px 7px',
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '4px',
|
gap: config.space.S100,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
color: color.Critical.Main,
|
|
||||||
fontSize: '13px',
|
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{localMicMuted && (
|
||||||
|
<div
|
||||||
|
aria-label={`Your microphone is muted (${localDisplayName})`}
|
||||||
|
title="Your microphone is muted"
|
||||||
|
style={{ ...badgeStyle, bottom: config.space.S200, left: config.space.S200 }}
|
||||||
>
|
>
|
||||||
<Icon size="100" src={Icons.MicMute} filled />
|
<Icon size="100" src={Icons.MicMute} filled style={{ color: color.Critical.Main }} />
|
||||||
|
<Text as="span" size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
You
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{allRemoteMuted && (
|
||||||
|
<div
|
||||||
|
aria-label="All other participants are muted"
|
||||||
|
title="All other participants are muted"
|
||||||
|
style={{ ...badgeStyle, top: config.space.S200, right: config.space.S200 }}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.MicMute} style={{ color: color.Warning.Main }} />
|
||||||
|
<Text as="span" size="T200" style={{ color: color.Warning.Main }}>
|
||||||
|
All muted
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,11 +720,27 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||||
|
const reduced = useReducedMotion();
|
||||||
const wallpaperStyle = React.useMemo(
|
const wallpaperStyle = React.useMemo(
|
||||||
() => getChatBg(chatBackground, isDark),
|
() => getChatBg(chatBackground, isDark, reduced),
|
||||||
[chatBackground, isDark],
|
[chatBackground, isDark, reduced],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 +767,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,23 +981,20 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
document.addEventListener('touchend', onTouchEnd);
|
document.addEventListener('touchend', onTouchEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
function applyResize(
|
||||||
e.stopPropagation();
|
el: HTMLElement,
|
||||||
e.preventDefault();
|
corner: Corner,
|
||||||
const el = callEmbedRef.current;
|
sx: number,
|
||||||
if (!el) return;
|
sy: number,
|
||||||
normaliseToTopLeft(el);
|
sw: number,
|
||||||
const sx = e.clientX;
|
sh: number,
|
||||||
const sy = e.clientY;
|
sl: number,
|
||||||
const sw = el.offsetWidth;
|
st: number,
|
||||||
const sh = el.offsetHeight;
|
cx: number,
|
||||||
const sl = parseFloat(el.style.left);
|
cy: number,
|
||||||
const st = parseFloat(el.style.top);
|
) {
|
||||||
document.body.style.cursor = `${corner}-resize`;
|
const dx = cx - sx;
|
||||||
document.body.style.userSelect = 'none';
|
const dy = cy - sy;
|
||||||
const onMove = (ev: MouseEvent) => {
|
|
||||||
const dx = ev.clientX - sx;
|
|
||||||
const dy = ev.clientY - sy;
|
|
||||||
let w = sw;
|
let w = sw;
|
||||||
let h = sh;
|
let h = sh;
|
||||||
let l = sl;
|
let l = sl;
|
||||||
@@ -771,6 +1027,24 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
el.style.height = `${h}px`;
|
el.style.height = `${h}px`;
|
||||||
el.style.left = `${l}px`;
|
el.style.left = `${l}px`;
|
||||||
el.style.top = `${t}px`;
|
el.style.top = `${t}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const el = callEmbedRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
normaliseToTopLeft(el);
|
||||||
|
const sx = e.clientX;
|
||||||
|
const sy = e.clientY;
|
||||||
|
const sw = el.offsetWidth;
|
||||||
|
const sh = el.offsetHeight;
|
||||||
|
const sl = parseFloat(el.style.left);
|
||||||
|
const st = parseFloat(el.style.top);
|
||||||
|
document.body.style.cursor = `${corner}-resize`;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
applyResize(el, corner, sx, sy, sw, sh, sl, st, ev.clientX, ev.clientY);
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
@@ -789,6 +1063,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,12 +1140,35 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
padding: '6px',
|
padding: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||||
|
{document.fullscreenEnabled && (
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePipFullscreen();
|
||||||
|
}}
|
||||||
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(0,0,0,0.65)',
|
background: 'rgba(0,0,0,0.65)',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
borderRadius: '6px',
|
borderRadius: config.radii.R300,
|
||||||
padding: '3px 8px',
|
padding: `${config.space.S100} ${config.space.S200}`,
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -849,6 +1178,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
↗ Return to call
|
↗ Return to call
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<PipMuteOverlay callEmbed={callEmbed} />
|
<PipMuteOverlay callEmbed={callEmbed} />
|
||||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
||||||
const s = corner.includes('s');
|
const s = corner.includes('s');
|
||||||
@@ -871,6 +1201,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,27 @@
|
|||||||
|
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';
|
||||||
|
import { useTauriDnd } from '../hooks/useTauriDnd';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Box, config, Icon, Icons, IconSrc, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { ThreadNotificationMode } from '../utils/threadNotifications';
|
||||||
|
import { useSetThreadNotificationMode } from '../hooks/useThreadNotifications';
|
||||||
|
import { AsyncStatus } from '../hooks/useAsyncCallback';
|
||||||
|
|
||||||
|
export const getThreadNotificationModeIcon = (mode?: ThreadNotificationMode): IconSrc => {
|
||||||
|
if (mode === ThreadNotificationMode.Mute) return Icons.BellMute;
|
||||||
|
if (mode === ThreadNotificationMode.MentionsOnly) return Icons.BellPing;
|
||||||
|
if (mode === ThreadNotificationMode.All) return Icons.BellRing;
|
||||||
|
|
||||||
|
return Icons.Bell;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useThreadNotificationModes = (): ThreadNotificationMode[] =>
|
||||||
|
useMemo(
|
||||||
|
() => [
|
||||||
|
ThreadNotificationMode.Default,
|
||||||
|
ThreadNotificationMode.All,
|
||||||
|
ThreadNotificationMode.MentionsOnly,
|
||||||
|
ThreadNotificationMode.Mute,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const useThreadNotificationModeStr = (): Record<ThreadNotificationMode, string> =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[ThreadNotificationMode.Default]: 'Default (participating)',
|
||||||
|
[ThreadNotificationMode.All]: 'All replies',
|
||||||
|
[ThreadNotificationMode.MentionsOnly]: 'Mentions only',
|
||||||
|
[ThreadNotificationMode.Mute]: 'Mute',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
type ThreadNotificationModeSwitcherProps = {
|
||||||
|
roomId: string;
|
||||||
|
threadId: string;
|
||||||
|
value?: ThreadNotificationMode;
|
||||||
|
children: (
|
||||||
|
handleOpen: MouseEventHandler<HTMLButtonElement>,
|
||||||
|
opened: boolean,
|
||||||
|
changing: boolean,
|
||||||
|
) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ThreadNotificationModeSwitcher({
|
||||||
|
roomId,
|
||||||
|
threadId,
|
||||||
|
value = ThreadNotificationMode.Default,
|
||||||
|
children,
|
||||||
|
}: ThreadNotificationModeSwitcherProps) {
|
||||||
|
const modes = useThreadNotificationModes();
|
||||||
|
const modeToStr = useThreadNotificationModeStr();
|
||||||
|
|
||||||
|
const { modeState, setMode } = useSetThreadNotificationMode(roomId, threadId);
|
||||||
|
const changing = modeState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (mode: ThreadNotificationMode) => {
|
||||||
|
if (changing) return;
|
||||||
|
setMode(mode);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<MenuItem
|
||||||
|
key={mode}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
radii="300"
|
||||||
|
disabled={changing}
|
||||||
|
onClick={() => handleSelect(mode)}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="100"
|
||||||
|
src={getThreadNotificationModeIcon(mode)}
|
||||||
|
filled={mode === value}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpenMenu, !!menuCords, changing)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,41 @@ 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)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<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,64 @@
|
|||||||
|
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
|
||||||
|
// Force a fresh element per slug so a recycled node whose previous slug
|
||||||
|
// 404'd (and was hidden in onError) can't leak `display:none` onto a
|
||||||
|
// valid decoration.
|
||||||
|
key={slug}
|
||||||
|
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"
|
||||||
|
onLoad={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.removeProperty('display');
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Text size="L400">Address (Optional)</Text>
|
<Text as="label" htmlFor="create-room-alias" size="L400">
|
||||||
|
Address (Optional)
|
||||||
|
</Text>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
Pick an unique address to make it discoverable.
|
Pick an unique address to make it discoverable.
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="create-room-alias"
|
||||||
ref={aliasInputRef}
|
ref={aliasInputRef}
|
||||||
onChange={handleAliasChange}
|
onChange={handleAliasChange}
|
||||||
before={
|
before={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type CustomEditorProps = {
|
|||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Explicit accessible name for the textbox; falls back to the placeholder. */
|
||||||
|
ariaLabel?: string;
|
||||||
onKeyDown?: KeyboardEventHandler;
|
onKeyDown?: KeyboardEventHandler;
|
||||||
onKeyUp?: KeyboardEventHandler;
|
onKeyUp?: KeyboardEventHandler;
|
||||||
onChange?: EditorChangeHandler;
|
onChange?: EditorChangeHandler;
|
||||||
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
maxHeight = '50vh',
|
maxHeight = '50vh',
|
||||||
editor,
|
editor,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
className={css.EditorTextarea}
|
className={css.EditorTextarea}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label={placeholder ?? 'Message input'}
|
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
renderPlaceholder={renderPlaceholder}
|
renderPlaceholder={renderPlaceholder}
|
||||||
renderElement={renderElement}
|
renderElement={renderElement}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
|
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { Box, MenuItem, Text, toRem } from 'folds';
|
import { Box, MenuItem, Text, toRem } from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
@@ -11,7 +11,7 @@ import { onTabPress } from '../../../utils/keyboard';
|
|||||||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||||
import { IEmoji, emojis } from '../../../plugins/emoji';
|
import { IEmoji, emojis, loadEmojiData } from '../../../plugins/emoji';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
@@ -47,13 +47,32 @@ export function EmoticonAutocomplete({
|
|||||||
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
||||||
const recentEmoji = useRecentEmoji(mx, 20);
|
const recentEmoji = useRecentEmoji(mx, 20);
|
||||||
|
|
||||||
|
// Lazily load emojibase data (see plugins/emoji `loadEmojiData`). Until it
|
||||||
|
// resolves, `emojis` is empty and autocomplete matches only custom-emoji
|
||||||
|
// packs; the unicode emoji list fills in once loaded.
|
||||||
|
const [loadedEmojis, setLoadedEmojis] = useState<IEmoji[]>(() => emojis);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
loadEmojiData()
|
||||||
|
// Fresh array reference: loadEmojiData populates the module-level array
|
||||||
|
// IN PLACE, so state set to the same ref would bail out of re-rendering
|
||||||
|
// and the search list would never gain the unicode emojis.
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive) setLoadedEmojis(loaded.emojis.slice());
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const searchList = useMemo(() => {
|
const searchList = useMemo(() => {
|
||||||
const list: Array<EmoticonSearchItem> = [];
|
const list: Array<EmoticonSearchItem> = [];
|
||||||
return list.concat(
|
return list.concat(
|
||||||
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
|
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
|
||||||
emojis,
|
loadedEmojis,
|
||||||
);
|
);
|
||||||
}, [imagePacks]);
|
}, [imagePacks, loadedEmojis]);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
searchList,
|
searchList,
|
||||||
|
|||||||
@@ -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,6 +49,7 @@ function UnknownMentionItem({
|
|||||||
}
|
}
|
||||||
onClick={() => handleAutocomplete(userId, name)}
|
onClick={() => handleAutocomplete(userId, name)}
|
||||||
before={
|
before={
|
||||||
|
<AvatarDecoration userId={userId}>
|
||||||
<PresenceRingAvatar userId={userId}>
|
<PresenceRingAvatar userId={userId}>
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -56,6 +58,7 @@ function UnknownMentionItem({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</PresenceRingAvatar>
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} size="B400">
|
<Text style={{ flexGrow: 1 }} size="B400">
|
||||||
@@ -177,6 +180,7 @@ export function UserMentionAutocomplete({
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
|
<AvatarDecoration userId={roomMember.userId}>
|
||||||
<PresenceRingAvatar userId={roomMember.userId}>
|
<PresenceRingAvatar userId={roomMember.userId}>
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -187,6 +191,7 @@ export function UserMentionAutocomplete({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</PresenceRingAvatar>
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, config, Icons, Scroll } from 'folds';
|
import { Box, config, Icons, Scroll } from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
@@ -15,7 +16,7 @@ import { isKeyHotkey } from 'is-hotkey';
|
|||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||||
@@ -56,6 +57,33 @@ import { VirtualTile } from '../virtualizer';
|
|||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
|
||||||
|
* `emojis`/`emojiGroups` arrays are populated in place once the promise
|
||||||
|
* resolves; we wrap them in a fresh object on load so React re-renders and the
|
||||||
|
* board fills in. Before that, both are empty and the board shows only custom
|
||||||
|
* image packs / recents (which is fleeting — the load starts on mount).
|
||||||
|
*/
|
||||||
|
const useEmojiData = (): EmojiData => {
|
||||||
|
const [data, setData] = useState<EmojiData>(() => ({ emojis, emojiGroups }));
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
loadEmojiData()
|
||||||
|
// Fresh array references (not just a fresh wrapper): downstream memos
|
||||||
|
// depend on the arrays themselves, which are populated IN PLACE — same
|
||||||
|
// refs would skip recompute and leave emoji search empty until remount.
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive)
|
||||||
|
setData({ emojis: loaded.emojis.slice(), emojiGroups: loaded.emojiGroups.slice() });
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
type EmojiGroupItem = {
|
type EmojiGroupItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +103,7 @@ const useGroups = (
|
|||||||
|
|
||||||
const recentEmojis = useRecentEmoji(mx, 21);
|
const recentEmojis = useRecentEmoji(mx, 21);
|
||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
|
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||||
|
|
||||||
const emojiGroupItems = useMemo(() => {
|
const emojiGroupItems = useMemo(() => {
|
||||||
const g: EmojiGroupItem[] = [];
|
const g: EmojiGroupItem[] = [];
|
||||||
@@ -99,7 +128,7 @@ const useGroups = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
emojiGroups.forEach((group) => {
|
loadedEmojiGroups.forEach((group) => {
|
||||||
g.push({
|
g.push({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: labels[group.id],
|
name: labels[group.id],
|
||||||
@@ -108,7 +137,7 @@ const useGroups = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return g;
|
return g;
|
||||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
|
||||||
|
|
||||||
const stickerGroupItems = useMemo(() => {
|
const stickerGroupItems = useMemo(() => {
|
||||||
const g: StickerGroupItem[] = [];
|
const g: StickerGroupItem[] = [];
|
||||||
@@ -177,6 +206,17 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
const usage = ImageUsage.Emoticon;
|
const usage = ImageUsage.Emoticon;
|
||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
const icons = useEmojiGroupIcons();
|
const icons = useEmojiGroupIcons();
|
||||||
|
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -198,8 +238,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;
|
||||||
@@ -225,7 +264,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SidebarDivider />
|
<SidebarDivider />
|
||||||
{emojiGroups.map((group) => (
|
{loadedEmojiGroups.map((group) => (
|
||||||
<GroupIcon
|
<GroupIcon
|
||||||
key={group.id}
|
key={group.id}
|
||||||
active={activeGroupId === group.id}
|
active={activeGroupId === group.id}
|
||||||
@@ -252,6 +291,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 +310,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;
|
||||||
@@ -391,13 +439,14 @@ export function EmojiBoard({
|
|||||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||||
const renderItem = useItemRenderer(tab);
|
const renderItem = useItemRenderer(tab);
|
||||||
|
const { emojis: loadedEmojis } = useEmojiData();
|
||||||
|
|
||||||
const searchList = useMemo(() => {
|
const searchList = useMemo(() => {
|
||||||
let list: Array<PackImageReader | IEmoji> = [];
|
let list: Array<PackImageReader | IEmoji> = [];
|
||||||
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
||||||
if (emojiTab) list = list.concat(emojis);
|
if (emojiTab) list = list.concat(loadedEmojis);
|
||||||
return list;
|
return list;
|
||||||
}, [emojiTab, usage, imagePacks]);
|
}, [emojiTab, usage, imagePacks, loadedEmojis]);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
searchList,
|
searchList,
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Name</Text>
|
<Text as="label" htmlFor="image-pack-name" size="L400">
|
||||||
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
|
Name
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
id="image-pack-name"
|
||||||
|
name="nameInput"
|
||||||
|
defaultValue={meta.name}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Attribution</Text>
|
<Text as="label" htmlFor="image-pack-attribution" size="L400">
|
||||||
|
Attribution
|
||||||
|
</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id="image-pack-attribution"
|
||||||
name="attributionTextArea"
|
name="attributionTextArea"
|
||||||
defaultValue={meta.attribution}
|
defaultValue={meta.attribution}
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -257,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">User ID</Text>
|
<Text as="label" htmlFor="invite-user-id" size="L400">
|
||||||
|
User ID
|
||||||
|
</Text>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
|
id="invite-user-id"
|
||||||
size="500"
|
size="500"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
@@ -330,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Reason (Optional)</Text>
|
<Text as="label" htmlFor="invite-reason" size="L400">
|
||||||
|
Reason (Optional)
|
||||||
|
</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id="invite-reason"
|
||||||
size="500"
|
size="500"
|
||||||
name="reasonInput"
|
name="reasonInput"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
@@ -349,7 +359,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}`,
|
||||||
@@ -106,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Address</Text>
|
<Text as="label" htmlFor="join-address" size="L400">
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="join-address"
|
||||||
size="500"
|
size="500"
|
||||||
autoFocus
|
autoFocus
|
||||||
name="addressInput"
|
name="addressInput"
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import katex from 'katex';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
type KaTeXProps = {
|
||||||
|
/** Raw LaTeX source (without `$`/`$$` delimiters). */
|
||||||
|
latex: string;
|
||||||
|
/** Render as block (display) math when true, inline otherwise. */
|
||||||
|
displayMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily-loaded KaTeX renderer.
|
||||||
|
*
|
||||||
|
* This module statically imports `katex` and its stylesheet, so both only enter
|
||||||
|
* the bundle via the dynamic `import()` of this file (see the `lazy()` wrapper
|
||||||
|
* in `react-custom-html-parser.tsx`). They are therefore NOT part of the eager
|
||||||
|
* import graph.
|
||||||
|
*
|
||||||
|
* We render with `throwOnError: false`, so KaTeX itself renders a parse error
|
||||||
|
* inline (in its error colour) rather than throwing. The HTML returned by
|
||||||
|
* `renderToString` is produced by our own trusted call from a fixed options
|
||||||
|
* object — it is safe to inject via `dangerouslySetInnerHTML`.
|
||||||
|
*/
|
||||||
|
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
|
||||||
|
const html = katex.renderToString(latex, {
|
||||||
|
displayMode,
|
||||||
|
throwOnError: false,
|
||||||
|
output: 'htmlAndMathml',
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = displayMode ? 'div' : 'span';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
// KaTeX output is generated by our own render call (trusted-safe).
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,13 +15,20 @@ 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) => {
|
||||||
|
const shortcode = reaction.startsWith('mxc://')
|
||||||
|
? 'custom emoji'
|
||||||
|
: (getShortcodeFor(getHexcodeForEmoji(reaction)) ?? reaction);
|
||||||
|
const label = `${shortcode} reaction, ${count} ${count === 1 ? 'person' : 'people'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
as="button"
|
as="button"
|
||||||
className={classNames(css.Reaction, className)}
|
className={classNames(css.Reaction, className)}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
shrink="No"
|
shrink="No"
|
||||||
gap="200"
|
gap="200"
|
||||||
|
aria-label={label}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
@@ -42,7 +49,8 @@ export const Reaction = as<
|
|||||||
{count}
|
{count}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</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,7 +38,9 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
shrink="No"
|
shrink="No"
|
||||||
className={css.ThreadIndicator}
|
className={css.ThreadIndicator}
|
||||||
@@ -47,9 +50,10 @@ export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Icon size="50" src={Icons.Thread} />
|
<Icon size="50" src={Icons.Thread} />
|
||||||
<Text size="L400">Thread</Text>
|
<Text size="L400">{t('Organisms.Message.thread')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
));
|
);
|
||||||
|
});
|
||||||
|
|
||||||
type ReplyProps = {
|
type ReplyProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -57,6 +61,7 @@ type ReplyProps = {
|
|||||||
replyEventId: string;
|
replyEventId: string;
|
||||||
threadRootId?: string | undefined;
|
threadRootId?: string | undefined;
|
||||||
onClick?: MouseEventHandler | undefined;
|
onClick?: MouseEventHandler | undefined;
|
||||||
|
onThreadClick?: ((threadRootId: string) => void) | undefined;
|
||||||
getMemberPowerTag?: GetMemberPowerTag;
|
getMemberPowerTag?: GetMemberPowerTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
@@ -70,6 +75,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
replyEventId,
|
replyEventId,
|
||||||
threadRootId,
|
threadRootId,
|
||||||
onClick,
|
onClick,
|
||||||
|
onThreadClick,
|
||||||
getMemberPowerTag,
|
getMemberPowerTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
@@ -103,10 +109,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={onThreadClick ? () => onThreadClick(threadRootId) : 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 && (
|
||||||
|
|||||||
@@ -99,9 +99,21 @@ export function AudioContent({
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
const audio = audioRef.current;
|
||||||
audioRef.current.playbackRate = playbackSpeed;
|
if (!audio) return undefined;
|
||||||
}
|
const applyRate = () => {
|
||||||
|
audio.playbackRate = playbackSpeed;
|
||||||
|
};
|
||||||
|
// Apply immediately, and re-apply whenever the media element (re)loads a new
|
||||||
|
// source — e.g. after async decrypt swaps in the blob URL — since the browser
|
||||||
|
// resets playbackRate to 1 on load, discarding the user's speed choice.
|
||||||
|
applyRate();
|
||||||
|
audio.addEventListener('loadedmetadata', applyRate);
|
||||||
|
audio.addEventListener('play', applyRate);
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('loadedmetadata', applyRate);
|
||||||
|
audio.removeEventListener('play', applyRate);
|
||||||
|
};
|
||||||
}, [playbackSpeed]);
|
}, [playbackSpeed]);
|
||||||
|
|
||||||
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
||||||
@@ -182,8 +194,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: '14px',
|
width: toRem(14),
|
||||||
height: '14px',
|
height: toRem(14),
|
||||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
border: `${config.borderWidth.B300} solid ${
|
||||||
borderRadius: '3px',
|
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
}`,
|
||||||
|
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||||
|
background: selected ? color.Primary.Main : 'transparent',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '10px',
|
color: color.Primary.OnMain,
|
||||||
color: '#fff',
|
|
||||||
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%' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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]}
|
||||||
|
|||||||