mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-30 21:20:08 +02:00
Compare commits
640 Commits
f3b3a3ae96
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 71e5897e54 | |||
| 74e5eaf535 | |||
| 92ae1c5531 | |||
| bba33cd103 | |||
| a7930ff6fc | |||
| aefdc9c82e | |||
| c5b8a32b87 | |||
| 0908bdf4cf | |||
| af9ecc5c18 | |||
| 0fe9a11482 | |||
| 640efbd276 | |||
| e156bdfee8 | |||
| 2511fc080d | |||
| 6378145a8f | |||
| 7fc4612a66 | |||
| a9cc6a2ce3 | |||
| f3772a3acb | |||
| 551c7993c6 | |||
| 2be92cc45f | |||
| 2c3e69a215 | |||
| 91ac716c34 | |||
| 0635378cef | |||
| 5f077effcc | |||
| 3dc7b07dd4 | |||
| 1e5cc91fc2 | |||
| 71709f1944 | |||
| b2498f0729 | |||
| 4cfb0973bc | |||
| f022ee6210 | |||
| 4da9e1cf46 | |||
| 9b2eb7ea36 | |||
| f71ac77377 | |||
| b3b1d68cc9 | |||
| c504c71191 | |||
| 62a54922e7 | |||
| 1a7f9ca952 | |||
| 807e39ce72 | |||
| a7b578e4cf | |||
| a51b0fd703 | |||
| 50b7005cf8 | |||
| 15a7d66cf6 | |||
| 6b728951fa | |||
| a811100038 | |||
| 512648da6d | |||
| 274a771d8b | |||
| 4050b310c9 | |||
| 8c13d14ed4 | |||
| 305d54eb8b | |||
| fc1c62d7c2 | |||
| 88699cb233 | |||
| 7e9d46ffde | |||
| 6b2afc1b34 | |||
| 26951a57c8 | |||
| e9743d0a73 | |||
| d4dd04608b | |||
| 003e0a511e | |||
| bb5a01ae49 | |||
| 0af5e33684 | |||
| 468696dfce | |||
| e8053612ff | |||
| 4b42ab7479 | |||
| 6b1b21c8d5 | |||
| 7468263627 | |||
| 7e7dc66743 | |||
| 8735d7bb46 | |||
| 1c346d82bc | |||
| 12b7423716 | |||
| 296e254718 | |||
| e6ec7e6150 | |||
| 387d38a496 | |||
| a05a17879c | |||
| 3e9bff2f1b | |||
| 1895d0f25c | |||
| 369f5130b5 | |||
| 520c41f280 | |||
| fb4750e9bc | |||
| 2735fcd749 | |||
| d28aa497c5 | |||
| 27a159c30c | |||
| f2050dc520 | |||
| 7d4a229d1f | |||
| e08de71e96 | |||
| deb27c3fa6 | |||
| 59d8a19c37 | |||
| 31e2d7da89 | |||
| 297e81f32c | |||
| 4a8418a0b3 | |||
| 03ebf7ea27 | |||
| 367cb3ed28 | |||
| 5ddb23a9e0 | |||
| e5ea66e5d5 | |||
| 046730215e | |||
| 163d75aa66 | |||
| ae212dca17 | |||
| ad5b9ab48d | |||
| 4570f254ea | |||
| b1bfb982d6 | |||
| 15fc0bdf1b | |||
| fbfaf7b93c | |||
| f9d3f7089b | |||
| 6618966ebc | |||
| bbb7d596bb | |||
| f980b0ee6e | |||
| 4eb781eaff | |||
| cb7d6b78b3 | |||
| 04ebfd0e78 | |||
| 6c1058a9d5 | |||
| 41ff04b49f | |||
| ed584a29a3 | |||
| 6151faf64b | |||
| 3fbcf5b6ef | |||
| 647a902e9f | |||
| 91cfbefd40 | |||
| 475ed452d2 | |||
| 92afc31ea5 | |||
| de5a6a07fd | |||
| 8d5e1fc7f9 | |||
| 621f53e00f | |||
| 7dbefdb951 | |||
| 0292cbb80c | |||
| e05da92d1b | |||
| 4d8a9dc6dc | |||
| 831a827ef7 | |||
| ab596a13b9 | |||
| b33821ba82 | |||
| 1a16a2116a | |||
| ded9ce6da2 | |||
| 93f07e8877 | |||
| 62a76330ca | |||
| cce4520a1a | |||
| 56e7f910ef | |||
| 120a4c3533 | |||
| 6720f1a294 | |||
| 12215e6a4a | |||
| e4c5959685 | |||
| 4e6b23cee8 | |||
| 91e7e8e1b6 | |||
| f582135303 | |||
| a815e88933 | |||
| 9f306399d1 | |||
| 20b0996915 | |||
| 5e95d142f0 | |||
| 357ac1a8bb | |||
| a8a14ae901 | |||
| cfda4c49d1 | |||
| 54572f9480 | |||
| 1b3e292883 | |||
| 12e47cc246 | |||
| fadead9c80 | |||
| 4c6f80277e | |||
| be61239529 | |||
| d812db9872 | |||
| bfcd3a0de2 | |||
| eeb50f15b5 | |||
| 2a5039b6c9 | |||
| 9bf380f2fc | |||
| 03bb1b5410 | |||
| ed1db05d94 | |||
| dcec53cdc3 | |||
| 8e3c1e0054 | |||
| 1bac1e34d6 | |||
| 12bb62702f | |||
| 02c792c09f | |||
| e3ea9881cc | |||
| 7c3af2ba6f | |||
| 63be5d2f7a | |||
| 4279595c20 | |||
| 0f738f25aa | |||
| 113b9e6b4a | |||
| 82339adff3 | |||
| 9e0116fce7 | |||
| 8624ac20fa | |||
| 1d7e41a4a1 | |||
| 91c056b5a3 | |||
| af389e645d | |||
| d0f8fbfada | |||
| cc08f29800 | |||
| 0357e81a78 | |||
| cde18c653c | |||
| d59a0a3894 | |||
| a64f4f6d7a | |||
| a314885bff | |||
| 9f59a1db8d | |||
| a8d1b7a905 | |||
| 84f46a5645 | |||
| b750d3fb9a | |||
| 45f3eacf21 | |||
| 1f5acd546d | |||
| 9b6ca42d9d | |||
| 7b7c1b4568 | |||
| 82122a4fab | |||
| 9ac18ac79e | |||
| 94000a5cc4 | |||
| b1056df80d | |||
| 8def445ba7 | |||
| 9afb13318f | |||
| 8b27dc2770 | |||
| 73daff5937 | |||
| ff999896b2 | |||
| 4d131703e7 | |||
| 36ee0feb36 | |||
| 843c53c065 | |||
| 1ffb2c8b95 | |||
| 0135d9bb29 | |||
| 044c1a42d4 | |||
| d2bae2301b | |||
| bee8c0af8e | |||
| 5abf882c94 | |||
| ea98de6244 | |||
| b6c4e65559 | |||
| c2854edabe | |||
| a2ab3a4025 | |||
| bb32b6e92b | |||
| 73493cb0aa | |||
| fbe290b788 | |||
| b10ceb9278 | |||
| 0b95d4f9a9 | |||
| e817d7392f | |||
| 6e8f9543eb | |||
| 6c8ed126b1 | |||
| 76ff4844db | |||
| 08e160ff69 | |||
| c72209f56d | |||
| 4b7db5f890 | |||
| 884744cb30 | |||
| bf9b66bdf4 | |||
| b51aef91cb | |||
| c66b42f03b | |||
| daf6681435 | |||
| 50ef184fa4 | |||
| 53654c2982 | |||
| f757366121 | |||
| 8ed3cff0e9 | |||
| a75c4b89f8 | |||
| 14d73b1532 | |||
| 0234abcca3 | |||
| 1c9e4621f5 | |||
| 607e09d271 | |||
| 4877010006 | |||
| 699bf620e5 | |||
| fe176f22ff | |||
| 1dfd0bf54c | |||
| ff1529920b | |||
| 17b582c904 | |||
| 1cbad7fb60 | |||
| 95c4a1d3e6 | |||
| d7220baaf6 | |||
| 2fffadfbcf | |||
| 648fcde085 | |||
| d0a52ecd23 | |||
| a2078c5780 | |||
| 21489fb717 | |||
| 8f802ecf28 | |||
| 163b5b0127 | |||
| ca57cd6590 | |||
| 58b9bdc1c5 | |||
| c1a35223d4 | |||
| feb04b036f | |||
| e5500f47be | |||
| 3dadc84179 | |||
| 493713b79a | |||
| d2d3dc8f13 | |||
| c26c562d0e | |||
| bc5db58b89 | |||
| cd832fb96a | |||
| 90a17bb0ba | |||
| 64b27ce7f1 | |||
| 6f94ee9aab | |||
| 5f2de3d633 | |||
| ec88fb9fbe | |||
| 34d10fd59b | |||
| faaddaca29 | |||
| 3106d77a71 | |||
| 914642154f | |||
| 7a2bbf0ce2 | |||
| 9b564303a4 | |||
| be2f07f24e | |||
| ff47255e07 | |||
| a1090fc064 | |||
| 47807db8cb | |||
| 8c21ecd9f0 | |||
| ddee396376 | |||
| 2b2dbc898e | |||
| c55d5f0ec7 | |||
| ae0152c28e | |||
| b9c874bed0 | |||
| eeb5288027 | |||
| 3d3ad27ca0 | |||
| 08033e99b6 | |||
| f9ab04ed17 | |||
| 729793dda2 | |||
| 97c79bef78 | |||
| 6d0cfdd8aa | |||
| c0ce0ff1ea | |||
| 20ed194cb0 | |||
| 4596ac4737 | |||
| 6efa86f5a1 | |||
| 746d94f226 | |||
| af006f2fde | |||
| 6f0d81814b | |||
| 2a861cb9b6 | |||
| 4b74e9df5a | |||
| be854bcd03 | |||
| 1dc00afe89 | |||
| 029ed2b3e2 | |||
| f862f8ad10 | |||
| baf50d8a62 | |||
| 8f16685ffd | |||
| 395a47cbdc | |||
| c342f40662 | |||
| 6560bd36ac | |||
| 26915b7003 | |||
| 1342c93d62 | |||
| 2370dff307 | |||
| e4f0abe74a | |||
| c42b126131 | |||
| 9feef5d119 | |||
| 2450971211 | |||
| cf1577cb44 | |||
| 318f0b141c | |||
| 8ddedb2d2d | |||
| e665b3815d | |||
| 7d2bb022a4 | |||
| e997a7131f | |||
| 3f58a3c9dd | |||
| 787f25705e | |||
| 761ef5ad6b | |||
| 75282fa5d2 | |||
| fb6358b3be | |||
| b570de6d4a | |||
| 0034f7847b | |||
| 3de0e30ad2 | |||
| 1be9d13cd4 | |||
| 1decec4c69 | |||
| 4df94478ba | |||
| db80d5e77d | |||
| decb96214a | |||
| 905999b9ae | |||
| c522ab21f1 | |||
| 4a72d77a96 | |||
| 1f1b996239 | |||
| ea82825823 | |||
| dd0293d2a6 | |||
| 2946b58f47 | |||
| cb1763160a | |||
| 7735eeeb08 | |||
| 492ba14047 | |||
| c68bbe9904 | |||
| be647242ab | |||
| 0e809a0a23 | |||
| 6893d578da | |||
| 4667f56b46 | |||
| 865ae0ab05 | |||
| a9d465017b | |||
| c8e3dc8a6e | |||
| 339b7d5ae9 | |||
| 18e3f48329 | |||
| 05bebed2c1 | |||
| fcf45178da | |||
| 8f60398cf5 | |||
| 7a8e8e4a73 | |||
| cc696701b5 | |||
| 93c34209b4 | |||
| 5565cfc6f6 | |||
| ab0783f64f | |||
| e0f7b733af | |||
| e438968bc1 | |||
| a00d834ff3 | |||
| 1f253fafaf | |||
| 69cd330257 | |||
| a814fe95aa | |||
| 503a7978f5 | |||
| 8d80ef43c6 | |||
| ea9d75f8ab | |||
| 9a789d514d | |||
| 10f14e1081 | |||
| 31bb8be576 | |||
| 60fc08b472 | |||
| 304413c1e3 | |||
| b81c353a51 | |||
| abc82e6988 | |||
| 033b53535e | |||
| 6babfb31fe | |||
| 167818556a | |||
| b8602bfb7b | |||
| 49d8f1c60b | |||
| b5b5776275 | |||
| 8296d31e67 | |||
| 581933f873 | |||
| cbe6b73d0b | |||
| d9f88fe987 | |||
| bd8551461b | |||
| 612ea945b4 | |||
| 2839710b09 | |||
| 596a4149f8 | |||
| 83cbbf681d | |||
| b9db931017 | |||
| dd631ac318 | |||
| f5486262d4 | |||
| 94c33f90ae | |||
| e2f6fbfc85 | |||
| 472508248e | |||
| 92c9488298 | |||
| c530405bf7 | |||
| 623a5bda77 | |||
| a5222b9e7d | |||
| 7b8bd84d37 | |||
| 805c3edbbc | |||
| 7726a6356b | |||
| 5fe2f8ddf5 | |||
| aa6ae21033 | |||
| 33e3ba0fb3 | |||
| 1594103f39 | |||
| 82698572f0 | |||
| 9a683a587b | |||
| 1e7857deac | |||
| 2b4d04a09a | |||
| 7288995c73 | |||
| a13b197d06 | |||
| e7114fa1b6 | |||
| 4db9cd0392 | |||
| ec6d224d71 | |||
| 5d36163eef | |||
| 9b313e04ba | |||
| 0d47e33ba1 | |||
| afbcf0b84b | |||
| 6ed1ef565b | |||
| 0a16b1a4e2 | |||
| 2e627ba6f5 | |||
| ad3346427f | |||
| 4e552bf3b9 | |||
| 64869a218e | |||
| 1934aee9e0 | |||
| cd1aba59a1 | |||
| 6b78bebae6 | |||
| e643b6b5ad | |||
| c93b3fbf9f | |||
| 2e628cdfe1 | |||
| 5c10a4e83b | |||
| c55f81694d | |||
| 1c79b7a049 | |||
| fbeb02fb7d | |||
| b12c3d679b | |||
| 6c6f2211cd | |||
| 4039698c71 | |||
| 5725255e4b | |||
| 30ecb00063 | |||
| 8a4cc88e39 | |||
| 2d5a8bae7d | |||
| d423406a28 | |||
| 77f1795697 | |||
| 689549ecab | |||
| 5ad4713373 | |||
| 10e8008185 | |||
| ace9845522 | |||
| 84f7103fd6 | |||
| 2964a2afcd | |||
| 39c8072ea1 | |||
| f83d23d34d | |||
| d8385ba89f | |||
| 1ef618f358 | |||
| b0bc6dd03c | |||
| 61a4176f47 | |||
| 6d76140e1d | |||
| 3f2ab3ff3c | |||
| 58d7c79541 | |||
| 895bf0f713 | |||
| 93e305620d | |||
| 684298b026 | |||
| b2b489e88f | |||
| e3d96c88b0 | |||
| 2b0d2847f5 | |||
| 20b725f706 | |||
| 7019cc46b1 | |||
| 002990fbbc | |||
| ac74a1a360 | |||
| a565772ec9 | |||
| cb99d03f98 | |||
| 2265def591 | |||
| 927e564a30 | |||
| 37c41a5311 | |||
| 2406486850 | |||
| 4bf08bb17e | |||
| 08ed9a25bc | |||
| a204006113 | |||
| cc2c45847f | |||
| a31f98a4a2 | |||
| 71e2d99711 | |||
| bd611cbcfa | |||
| 1751929f87 | |||
| 980b2b7e12 | |||
| ccfd4a3dd7 | |||
| 9f19a65fc6 | |||
| 7af8b19a72 | |||
| 752c75b37c | |||
| d3e44523bd | |||
| cb0c241580 | |||
| 2d4d6816d3 | |||
| 1959497071 | |||
| ecce12a9fc | |||
| 33c8d97403 | |||
| b4bf4e2567 | |||
| 66a5a1ff15 | |||
| 37dda6962d | |||
| 236e2e85de | |||
| f87dde04f8 | |||
| 20457ff082 | |||
| 8decb568fe | |||
| 38cad7d2e3 | |||
| 50a037686b | |||
| 5a27e6fb51 | |||
| bbbb79eaca | |||
| 1cbafbeaeb | |||
| 29a7d7c9c6 | |||
| a0da1bb3d5 | |||
| c9e7dd24a4 | |||
| 918d1f0960 | |||
| bd18cd5e72 | |||
| d4eacff506 | |||
| 84502fc4af | |||
| 50eb33bbd8 | |||
| 8ef9b3a89f | |||
| 5993978f33 | |||
| 2dab3733fd | |||
| 7a0b0b8bd4 | |||
| b5442934b9 | |||
| e40746dee4 | |||
| a33faf6b1a | |||
| 564f5bc73c | |||
| 0b59423f61 | |||
| d9080205e5 | |||
| 91692229ae | |||
| 858fb076da | |||
| d838c4a9e5 | |||
| c86cf5e5ed | |||
| 8f57fbb572 | |||
| 1c587e17d2 | |||
| 14558238c5 | |||
| f7ebbe2135 | |||
| 1fbb19d50b | |||
| a5ea69ba22 | |||
| 7c24e2038d | |||
| 916a0f94db | |||
| bf687a2405 | |||
| 5ff078e351 | |||
| 1b29ade8d3 | |||
| 48d84363ec | |||
| 1d4026a89c | |||
| 48c1dd98fa | |||
| 949c64d17b | |||
| d0f6ab6574 | |||
| e35e190ba1 | |||
| cbd41598b5 | |||
| a6092efd94 | |||
| b4839c8ed5 | |||
| 2952f4d415 | |||
| 1bccbb30a4 | |||
| e161994783 | |||
| 0a1b0162c6 | |||
| 7f8972e71e | |||
| 131358cac7 | |||
| 982689564f | |||
| 6ff2058230 | |||
| 6d471c0e3f | |||
| a69945c119 | |||
| 0197e6ecd8 | |||
| cf7b814ad6 | |||
| 05ba36736c | |||
| 2e3a3e7240 | |||
| 397dacc51a | |||
| 7a4014f846 | |||
| 0a3597f84e | |||
| 27f913552e | |||
| dbc98cdc4e | |||
| 722cc615cb | |||
| 7e312a0e8c | |||
| ddd191e332 | |||
| 9cecb6a45a | |||
| 9ce33e337f | |||
| 2b1d0ff344 | |||
| bcee6b5298 | |||
| b3b655d8ac | |||
| b2cd8a1d68 | |||
| f840dabe43 | |||
| e22f214210 | |||
| 642d222faa | |||
| 0af08e7741 | |||
| fb9ec31c93 | |||
| 0b067cad89 | |||
| 5846fb7adb | |||
| c30d7265ff | |||
| 694183fb41 | |||
| 4a10ab70e8 | |||
| dfce6760ca | |||
| c4f75e50d7 | |||
| 739418733d | |||
| 4e5acb990f | |||
| fab14abdd6 | |||
| 290679fb24 | |||
| 24d45d5967 | |||
| 0a0af0db75 | |||
| 3b38a4ae61 | |||
| bc91653d38 | |||
| 2b739fd2b6 | |||
| f0b7d74555 | |||
| ba7ffa5497 | |||
| 3c37071011 | |||
| 1514012c4c | |||
| 3a05f69ad5 | |||
| 9bf26184ac | |||
| ab4dde0836 | |||
| a1f4a22609 | |||
| 98fbadae2f | |||
| 60b49c71e1 | |||
| 91deb37c62 | |||
| dbbaf7732f | |||
| 2cfa833e6b | |||
| ac87cf9a90 | |||
| 81acfa8734 | |||
| f8daa6ff4c | |||
| ecbb0612ec | |||
| ded6b0ccf0 | |||
| 0fa6e48fd7 | |||
| 6dfea34dd2 | |||
| b2bd7693bb | |||
| 0b636df330 | |||
| 954735928c | |||
| f2a68fbbdf | |||
| 56a8713374 | |||
| d9bc93640d | |||
| 62e21d54fb | |||
| cf43938fd3 | |||
| a07ae7941f | |||
| da9cb3dab6 | |||
| 39e9631812 | |||
| 2d3cd9a3b3 | |||
| 1e25255ac5 | |||
| 7303cd0b33 | |||
| cacbf742d1 | |||
| 29b83c5b89 |
@@ -0,0 +1,40 @@
|
||||
# Contributing to VRCX
|
||||
|
||||
Thank you for your interest in contributing to VRCX! Here are a few guidelines to help things go smoothly.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- **Large changes require prior discussion.** If your PR involves significant new features, refactors, or architectural changes, please open an issue first to discuss the approach. PRs submitted without prior discussion may not be accepted.
|
||||
- **Small fixes are always welcome.** Typo fixes, bug fixes, and minor improvements can be submitted directly.
|
||||
- **UI-related PRs will most likely be declined.** To maintain a consistent and cohesive user interface, PRs that modify UI elements (layouts, styling, visual components, etc.) are generally not accepted. If you have a UI suggestion or improvement idea, please [Open an issue](https://github.com/vrcx-team/VRCX/issues/new) instead so we can discuss it with the team.
|
||||
|
||||
## Important Considerations
|
||||
|
||||
VRCX is used by a diverse, international community with users from many different countries and cultures. Because of this, we need to ensure that contributions are broadly applicable. Before submitting a PR, please consider:
|
||||
|
||||
- **Is this feature broadly useful and valuable?** Niche or low-value features increase maintenance burden without proportional benefits and may not be accepted.
|
||||
- **Could it negatively impact other features?** Changes that interfere with or degrade existing functionality will not be merged.
|
||||
|
||||
Please think carefully about these factors before investing time in a contribution.
|
||||
|
||||
### Performance Design Baseline
|
||||
|
||||
VRCX is designed to perform well for users with **1,000 to 4,000 friends**. For any changes involving data operations, the design should be able to handle databases up to **8 GB** in size. Please keep these baselines in mind when implementing features or optimizations.
|
||||
|
||||
**PRs that do not follow these guidelines may be closed without review.**
|
||||
|
||||
## Looking for Something to Work On?
|
||||
|
||||
Check out issues labeled [`PR welcome`](https://github.com/vrcx-team/VRCX/issues?q=label%3A%22pr+welcome%22+is%3Aclosed) — these are contributions we'd love to see.
|
||||
|
||||
- These issues are listed under **Closed** issues, not Open, so make sure to check the closed tab. We keep them in Closed to avoid cluttering the Open issues list.
|
||||
- You can link the issue in your PR, and once merged, we will mark the issue as completed.
|
||||
- Even for `PR welcome` issues, if the change involves core functionality, please discuss it in the issue first.
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. Fork the repository and create your branch from `master`.
|
||||
2. Make your changes and test them locally.
|
||||
3. Open a pull request with a clear description of what you changed and why.
|
||||
|
||||
Thanks for helping make VRCX better!
|
||||
+1
-2
@@ -1,3 +1,2 @@
|
||||
github: [natsumi-sama]
|
||||
ko_fi: map1en_
|
||||
patreon: Natsumi_VRCX
|
||||
ko_fi: natsumi_sama
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Check if we want to sign
|
||||
id: check_sign
|
||||
run: |
|
||||
echo "sign=${{ secrets.AZURE_CLIENT_ID != '' }}" >> $GITHUB_OUTPUT
|
||||
echo "sign=${{ secrets.AZURE_CLIENT_ID != '' }}" >> $GITHUB_OUTPUT
|
||||
- name: Azure login
|
||||
if: steps.check_sign.outputs.sign == 'true'
|
||||
uses: azure/login@v2
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Mark and close stale issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-label: Stale
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 14
|
||||
exempt-issue-labels: Bug,Planned,Pinned,Security,Done,In Progress
|
||||
operations-per-run: 100
|
||||
close-issue-message: >
|
||||
Closing this issue due to inactivity.
|
||||
@@ -10,3 +10,7 @@ Installer/version_define.nsh
|
||||
bun.lock
|
||||
|
||||
.env.sentry-build-plugin
|
||||
AGENTS.md
|
||||
AI_GUIDE.md
|
||||
CLAUDE.md
|
||||
coverage/
|
||||
@@ -1,22 +1,15 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.js",
|
||||
"options": {
|
||||
"parser": "meriyah"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.vue",
|
||||
"files": ["*.vue"],
|
||||
"options": {
|
||||
"printWidth": 120,
|
||||
"bracketSameLine": true,
|
||||
+1664
File diff suppressed because it is too large
Load Diff
Vendored
+1
-2
@@ -3,8 +3,7 @@
|
||||
"vue.volar",
|
||||
"lokalise.i18n-ally",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"lllllllqw.jsdoc",
|
||||
"oxc.oxc-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -3,7 +3,8 @@
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.sourceLanguage": "en",
|
||||
"i18n-ally.indent": 4,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"oxc.fmt.configPath": ".oxfmtrc.json",
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"omnisharp.enableRoslynAnalyzers": true,
|
||||
"omnisharp.useModernNet": false,
|
||||
|
||||
@@ -46,6 +46,18 @@ namespace VRCX
|
||||
}
|
||||
}
|
||||
|
||||
public void OpenDiscordProfile(string discordId)
|
||||
{
|
||||
if (!long.TryParse(discordId, out _))
|
||||
throw new Exception("Invalid user ID");
|
||||
|
||||
var uri = $"discord://-/users/{discordId}";
|
||||
Process.Start(new ProcessStartInfo(uri)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
|
||||
public string GetLaunchCommand()
|
||||
{
|
||||
var command = StartupArgs.LaunchArguments.LaunchCommand;
|
||||
|
||||
+212
-16
@@ -4,8 +4,11 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using Microsoft.Win32;
|
||||
using NLog;
|
||||
|
||||
namespace VRCX
|
||||
@@ -28,10 +31,11 @@ namespace VRCX
|
||||
public readonly string AppShortcutVR;
|
||||
|
||||
private DateTime startTime = DateTime.Now;
|
||||
private Dictionary<string, HashSet<int>> startedProcesses = new Dictionary<string, HashSet<int>>();
|
||||
private Dictionary<string, HashSet<int>> startedProcesses = new();
|
||||
private readonly Timer childUpdateTimer;
|
||||
private int timerTicks = 0;
|
||||
private static readonly byte[] shortcutSignatureBytes = { 0x4C, 0x00, 0x00, 0x00 }; // signature for ShellLinkHeader
|
||||
private static readonly byte[] urlShortcutHeader = "[{000214A0-0000-0000-C000-000000000046}]"u8.ToArray(); // .url file header
|
||||
|
||||
private const uint TH32CS_SNAPPROCESS = 2;
|
||||
|
||||
@@ -102,11 +106,13 @@ namespace VRCX
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private void OnProcessStarted(MonitoredProcess monitoredProcess)
|
||||
{
|
||||
if (!Enabled || !monitoredProcess.HasName(VRChatProcessName) || monitoredProcess.Process.StartTime < startTime)
|
||||
return;
|
||||
|
||||
// Start auto start processes
|
||||
lock (startedProcesses)
|
||||
{
|
||||
if (KillChildrenOnExit)
|
||||
@@ -114,8 +120,10 @@ namespace VRCX
|
||||
else
|
||||
UpdateChildProcesses();
|
||||
|
||||
var shortcutFiles = FindShortcutFiles(AppShortcutDirectory);
|
||||
shortcutFiles.AddRange(FindShortcutFiles(Program.AppApiInstance.IsSteamVRRunning() ? AppShortcutVR : AppShortcutDesktop));
|
||||
var (shortcutFiles, steamIds) = FindShortcutFiles(AppShortcutDirectory);
|
||||
var (platformShortcutFiles, platformSteamIds) = FindShortcutFiles(Program.AppApiInstance.IsSteamVRRunning() ? AppShortcutVR : AppShortcutDesktop);
|
||||
shortcutFiles.AddRange(platformShortcutFiles);
|
||||
steamIds.AddRange(platformSteamIds);
|
||||
foreach (var file in shortcutFiles)
|
||||
{
|
||||
if (RunProcessOnce && IsProcessRunning(file))
|
||||
@@ -126,8 +134,12 @@ namespace VRCX
|
||||
|
||||
StartChildProcess(file);
|
||||
}
|
||||
foreach (var steamId in steamIds)
|
||||
{
|
||||
StartSteamGame(steamId);
|
||||
}
|
||||
|
||||
if (shortcutFiles.Count == 0)
|
||||
if (shortcutFiles.Count == 0 && steamIds.Count == 0)
|
||||
return;
|
||||
|
||||
timerTicks = 0;
|
||||
@@ -143,6 +155,7 @@ namespace VRCX
|
||||
{
|
||||
UpdateChildProcesses(); // Ensure the list contains all current child processes.
|
||||
|
||||
// Stop auto start processes
|
||||
Parallel.ForEach(startedProcesses.ToArray(), pair =>
|
||||
{
|
||||
var processes = pair.Value;
|
||||
@@ -216,11 +229,12 @@ namespace VRCX
|
||||
if (proc.HasExited)
|
||||
continue;
|
||||
|
||||
if (proc.CloseMainWindow())
|
||||
continue;
|
||||
|
||||
if (proc.WaitForExit(1000))
|
||||
continue;
|
||||
// breaks some apps
|
||||
// if (proc.CloseMainWindow())
|
||||
// continue;
|
||||
//
|
||||
// if (proc.WaitForExit(1000))
|
||||
// continue;
|
||||
|
||||
proc.Kill();
|
||||
}
|
||||
@@ -345,22 +359,44 @@ namespace VRCX
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The folder path.</param>
|
||||
/// <returns>An array of shortcut paths. If none, then empty.</returns>
|
||||
private static List<string> FindShortcutFiles(string folderPath)
|
||||
private static Tuple<List<string>, List<string>> FindShortcutFiles(string folderPath)
|
||||
{
|
||||
DirectoryInfo directoryInfo = new DirectoryInfo(folderPath);
|
||||
FileInfo[] files = directoryInfo.GetFiles();
|
||||
List<string> ret = new List<string>();
|
||||
var directoryInfo = new DirectoryInfo(folderPath);
|
||||
var files = directoryInfo.GetFiles();
|
||||
var shortcuts = new List<string>();
|
||||
var steamIds = new List<string>();
|
||||
|
||||
foreach (FileInfo file in files)
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (IsShortcutFile(file.FullName))
|
||||
{
|
||||
ret.Add(file.FullName);
|
||||
shortcuts.Add(file.FullName);
|
||||
continue;
|
||||
}
|
||||
if (IsUrlShortcutFile(file.FullName))
|
||||
{
|
||||
try
|
||||
{
|
||||
const string urlPrefix = "URL=steam://rungameid/";
|
||||
var lines = File.ReadAllLines(file.FullName);
|
||||
var urlLine = lines.FirstOrDefault(l => l.StartsWith(urlPrefix));
|
||||
if (urlLine == null)
|
||||
continue;
|
||||
|
||||
var appId = urlLine[urlPrefix.Length..].Trim();
|
||||
steamIds.Add(appId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, "Error reading shortcut file: {0}", file.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
return new Tuple<List<string>, List<string>>(shortcuts, steamIds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified file path is a shortcut by checking the file header.
|
||||
@@ -377,5 +413,165 @@ namespace VRCX
|
||||
|
||||
return headerBytes.SequenceEqual(shortcutSignatureBytes);
|
||||
}
|
||||
|
||||
private static bool IsUrlShortcutFile(string filePath)
|
||||
{
|
||||
var headerBytes = new byte[urlShortcutHeader.Length];
|
||||
using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
if (fileStream.Length < headerBytes.Length)
|
||||
return false;
|
||||
fileStream.ReadExactly(headerBytes, 0, headerBytes.Length);
|
||||
|
||||
return headerBytes.SequenceEqual(urlShortcutHeader);
|
||||
}
|
||||
|
||||
// Steam shortcuts
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
public async Task StartSteamGame(string appId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo($"steam://launch/{appId}")
|
||||
{
|
||||
UseShellExecute = true
|
||||
};
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, "Error starting steam game with appid {0}", appId);
|
||||
}
|
||||
|
||||
var appDirPath = GetPathWithAppId(appId);
|
||||
if (appDirPath == null)
|
||||
return;
|
||||
|
||||
// wait for Steam to start the process
|
||||
const int retryLimit = 10;
|
||||
for (var i = 0; i < retryLimit; i++)
|
||||
{
|
||||
// find running process from path
|
||||
var processes = Process.GetProcesses();
|
||||
var foundProcess = processes.FirstOrDefault(p =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return !p.HasExited &&
|
||||
p.MainModule?.FileName != null &&
|
||||
p.MainModule.FileName.StartsWith(appDirPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (foundProcess?.MainModule?.FileName == null)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
var processPath = foundProcess.MainModule.FileName;
|
||||
logger.Info("Found process for appid {0}: {1} (PID: {2})", appId, processPath, foundProcess.Id);
|
||||
lock (startedProcesses)
|
||||
{
|
||||
startedProcesses.Add(processPath, new HashSet<int>() { foundProcess.Id });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
logger.Error("Failed to find process for appid {0} after starting. Steam may have failed to launch the game or it may have taken too long to start.", appId);
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static string? GetPathWithAppId(string appId)
|
||||
{
|
||||
string? steamPath = null;
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Wow6432Node\Valve\Steam");
|
||||
if (key?.GetValue("InstallPath") is string path)
|
||||
steamPath = path;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored
|
||||
}
|
||||
|
||||
if (steamPath == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Valve\Steam");
|
||||
if (key?.GetValue("SteamPath") is string path)
|
||||
steamPath = path.Replace("/", "\\");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored
|
||||
}
|
||||
}
|
||||
|
||||
if (steamPath == null)
|
||||
{
|
||||
logger.Error("Cant find Steam install path");
|
||||
return null;
|
||||
}
|
||||
var libraryFoldersVdfPath = Path.Join(steamPath, @"config\libraryfolders.vdf");
|
||||
if (!File.Exists(libraryFoldersVdfPath))
|
||||
{
|
||||
logger.Error("Cant find Steam libraryfolders.vdf");
|
||||
return null;
|
||||
}
|
||||
|
||||
var libraryFolders = new List<string>();
|
||||
foreach (var line in File.ReadLines(libraryFoldersVdfPath))
|
||||
{
|
||||
if (!line.Contains("\"path\""))
|
||||
continue;
|
||||
|
||||
var parts = line.Split("\t");
|
||||
if (parts.Length < 4)
|
||||
continue;
|
||||
|
||||
var basePath = parts[4].Replace("\"", "").Replace(@"\\", @"\");
|
||||
var path = Path.Join(basePath, @"steamapps");
|
||||
if (Directory.Exists(path))
|
||||
libraryFolders.Add(path);
|
||||
}
|
||||
|
||||
foreach (var libraryPath in libraryFolders)
|
||||
{
|
||||
var appManifestFiles = Directory.GetFiles(libraryPath, "appmanifest_*.acf");
|
||||
foreach (var file in appManifestFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var acf = File.ReadAllText(file);
|
||||
var idMatch = Regex.Match(acf, @"""appid""\s+""(\d+)""");
|
||||
var dirMatch = Regex.Match(acf, @"""installdir""\s+""([^""]+)""");
|
||||
if (!idMatch.Success || !dirMatch.Success)
|
||||
continue;
|
||||
|
||||
var foundAppId = idMatch.Groups[1].Value;
|
||||
if (foundAppId != appId)
|
||||
continue;
|
||||
|
||||
var fullPath = Path.Join(libraryPath, "common", dirMatch.Groups[1].Value);
|
||||
if (Directory.Exists(fullPath))
|
||||
return fullPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Error("Could not find install dir for appid {0}", appId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-13
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Forms;
|
||||
@@ -24,15 +24,19 @@ namespace VRCX
|
||||
/// <summary>
|
||||
/// Private holder of current theme
|
||||
/// </summary>
|
||||
private static int currentTheme;
|
||||
private static int currentTheme = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the global theme of the app
|
||||
/// Light = 0
|
||||
/// Dark = 1
|
||||
/// Midnight = 2
|
||||
/// </summary>
|
||||
public static void SetGlobalTheme(int theme)
|
||||
{
|
||||
if (currentTheme == theme)
|
||||
return;
|
||||
|
||||
currentTheme = theme;
|
||||
|
||||
//Make a seperate list for all current forms (causes issues otherwise)
|
||||
@@ -91,18 +95,20 @@ namespace VRCX
|
||||
|
||||
private static void SetThemeToGlobal(IntPtr handle)
|
||||
{
|
||||
int whiteColor = 0xFFFFFF;
|
||||
int blackColor = 0x000000;
|
||||
if (GetTheme(handle) != currentTheme)
|
||||
{
|
||||
if (PInvoke.DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, ref currentTheme, sizeof(int)) != 0)
|
||||
PInvoke.DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref currentTheme, sizeof(int));
|
||||
var whiteColor = 0xFFFFFF;
|
||||
var blackColor = 0x000000;
|
||||
var greyColor = 0x2B2B2B;
|
||||
|
||||
var isDark = currentTheme > 0 ? 1 : 0;
|
||||
if (PInvoke.DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, ref isDark, sizeof(int)) != 0)
|
||||
PInvoke.DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref isDark, sizeof(int));
|
||||
|
||||
if (currentTheme == 1)
|
||||
PInvoke.DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref blackColor, sizeof(int));
|
||||
else
|
||||
PInvoke.DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref whiteColor, sizeof(int));
|
||||
}
|
||||
if (currentTheme == 2)
|
||||
PInvoke.DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref blackColor, sizeof(int));
|
||||
else if (currentTheme == 1)
|
||||
PInvoke.DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref greyColor, sizeof(int));
|
||||
else
|
||||
PInvoke.DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref whiteColor, sizeof(int));
|
||||
}
|
||||
|
||||
private static int GetTheme(IntPtr handle)
|
||||
|
||||
+17
-10
@@ -5,6 +5,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using NLog;
|
||||
|
||||
@@ -32,6 +33,8 @@ namespace VRCX
|
||||
private DateTime tillDate;
|
||||
public bool VrcClosedGracefully;
|
||||
private readonly ConcurrentQueue<string> m_LogQueue = new ConcurrentQueue<string>(); // for electron
|
||||
private static readonly Regex CleanId = new("[^a-zA-Z0-9_\\-~:()]", RegexOptions.Compiled);
|
||||
private static readonly Regex CleanLocation = new("[/]", RegexOptions.Compiled);
|
||||
|
||||
// NOTE
|
||||
// FileSystemWatcher() is unreliable
|
||||
@@ -371,6 +374,7 @@ namespace VRCX
|
||||
return true;
|
||||
|
||||
var location = line.Substring(lineOffset);
|
||||
location = CleanLocation.Replace(location, string.Empty);
|
||||
|
||||
AppendLog(new[]
|
||||
{
|
||||
@@ -447,7 +451,8 @@ namespace VRCX
|
||||
if (lineOffset >= line.Length)
|
||||
return true;
|
||||
|
||||
logContext.LocationDestination = line.Substring(lineOffset);
|
||||
var locationDestination = line.Substring(lineOffset);
|
||||
logContext.LocationDestination = CleanLocation.Replace(locationDestination, string.Empty);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -495,8 +500,8 @@ namespace VRCX
|
||||
fileInfo.Name,
|
||||
ConvertLogTimeToISO8601(line),
|
||||
"player-joined",
|
||||
userInfo.DisplayName ?? string.Empty,
|
||||
userInfo.UserId ?? string.Empty
|
||||
userInfo.DisplayName,
|
||||
userInfo.UserId
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -1351,14 +1356,15 @@ namespace VRCX
|
||||
|
||||
var inventoryIdIndex = info.IndexOf("inv_", StringComparison.Ordinal);
|
||||
var inventoryId = info.Substring(inventoryIdIndex);
|
||||
inventoryId = CleanId.Replace(inventoryId, string.Empty);
|
||||
|
||||
AppendLog(new[]
|
||||
{
|
||||
fileInfo.Name,
|
||||
ConvertLogTimeToISO8601(line),
|
||||
"sticker-spawn",
|
||||
userId ?? string.Empty,
|
||||
displayName ?? string.Empty,
|
||||
userId,
|
||||
displayName,
|
||||
inventoryId
|
||||
});
|
||||
|
||||
@@ -1400,21 +1406,22 @@ namespace VRCX
|
||||
return new string[][] { };
|
||||
}
|
||||
|
||||
private static (string? DisplayName, string? UserId) ParseUserInfo(string userInfo)
|
||||
private static (string DisplayName, string UserId) ParseUserInfo(string userInfo)
|
||||
{
|
||||
string? userDisplayName;
|
||||
string? userId;
|
||||
string userDisplayName;
|
||||
string userId;
|
||||
|
||||
int pos = userInfo.LastIndexOf(" (", StringComparison.Ordinal);
|
||||
var pos = userInfo.LastIndexOf(" (", StringComparison.Ordinal);
|
||||
if (pos >= 0)
|
||||
{
|
||||
userDisplayName = userInfo.Substring(0, pos);
|
||||
userId = userInfo.Substring(pos + 2, userInfo.LastIndexOf(')') - (pos + 2));
|
||||
userId = CleanId.Replace(userId, string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
userDisplayName = userInfo;
|
||||
userId = null;
|
||||
userId = string.Empty;
|
||||
}
|
||||
|
||||
return (userDisplayName, userId);
|
||||
|
||||
@@ -17,8 +17,10 @@ public static class OverlayClient
|
||||
private static readonly Uri WebsocketUri = new("ws://127.0.0.1:34582");
|
||||
private static WebsocketClient? _websocketClient;
|
||||
|
||||
public static bool Connected =>
|
||||
_websocketClient != null && _websocketClient.IsRunning;
|
||||
public static bool ConnectedAndActive =>
|
||||
_websocketClient != null && _websocketClient.IsRunning &&
|
||||
Connected &&
|
||||
OverlayProgram.VRCXVRInstance.IsActive();
|
||||
|
||||
public static async Task Init()
|
||||
|
||||
@@ -42,7 +42,9 @@ internal static class OverlayProgram
|
||||
private static async Task QuitProcess()
|
||||
{
|
||||
await Task.Delay(5000);
|
||||
while (OverlayClient.ConnectedAndActive)
|
||||
while (Program.LaunchDebug ?
|
||||
OverlayClient.Connected :
|
||||
OverlayClient.ConnectedAndActive)
|
||||
{
|
||||
await Task.Delay(500);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace VRCX
|
||||
private readonly List<string[]> _deviceList;
|
||||
private readonly ReaderWriterLockSlim _deviceListLock;
|
||||
private bool _active;
|
||||
private bool _isOverlayStarted;
|
||||
private bool _menuButton;
|
||||
private int _overlayHand;
|
||||
private GLTextureWriter _overlayTextureWriter;
|
||||
@@ -213,6 +214,7 @@ namespace VRCX
|
||||
|
||||
active = true;
|
||||
SetupTextures();
|
||||
_isOverlayStarted = true;
|
||||
}
|
||||
|
||||
while (system.PollNextEvent(ref e, (uint)Marshal.SizeOf(e)))
|
||||
@@ -220,6 +222,7 @@ namespace VRCX
|
||||
var type = (EVREventType)e.eventType;
|
||||
if (type == EVREventType.VREvent_Quit)
|
||||
{
|
||||
_isOverlayStarted = false;
|
||||
active = false;
|
||||
IsHmdAfk = false;
|
||||
OpenVR.Shutdown();
|
||||
@@ -290,6 +293,7 @@ namespace VRCX
|
||||
else if (active)
|
||||
{
|
||||
active = false;
|
||||
_isOverlayStarted = false;
|
||||
IsHmdAfk = false;
|
||||
OpenVR.Shutdown();
|
||||
_deviceListLock.EnterWriteLock();
|
||||
@@ -848,9 +852,11 @@ namespace VRCX
|
||||
|
||||
public override void ExecuteVrOverlayFunction(string function, string json)
|
||||
{
|
||||
//if (_hmdOverlaySocket == null || !_hmdOverlaySocket.Connected) return;
|
||||
// if (_hmdOverlay.IsLoading)
|
||||
// Restart();
|
||||
if (!_isOverlayStarted)
|
||||
{
|
||||
_overlayFunctionQueue.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
_overlayFunctionQueue.Enqueue(new KeyValuePair<string, string>(function, json));
|
||||
}
|
||||
|
||||
@@ -16,20 +16,20 @@ namespace VRCX;
|
||||
public class OverlayServer
|
||||
{
|
||||
public static OverlayServer Instance { get; private set; }
|
||||
|
||||
|
||||
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
|
||||
private static readonly Lock SendLock = new();
|
||||
private static readonly ConcurrentDictionary<WebSocket, byte> ConnectedWebSockets = new();
|
||||
private static CancellationTokenSource _cancellationToken;
|
||||
|
||||
|
||||
private static OverlayVars _overlayVars;
|
||||
|
||||
static OverlayServer()
|
||||
{
|
||||
Instance = new OverlayServer();
|
||||
}
|
||||
|
||||
|
||||
public async Task Init()
|
||||
{
|
||||
if (_cancellationToken != null && _cancellationToken.IsCancellationRequested)
|
||||
@@ -66,7 +66,7 @@ public class OverlayServer
|
||||
{
|
||||
if (_cancellationToken == null || _cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
|
||||
foreach (var webSocket in ConnectedWebSockets.Keys)
|
||||
{
|
||||
if (webSocket == null || webSocket.State != WebSocketState.Open)
|
||||
@@ -90,7 +90,7 @@ public class OverlayServer
|
||||
{
|
||||
WebSocketContext webSocketContext;
|
||||
try
|
||||
{
|
||||
{
|
||||
webSocketContext = await listenerContext.AcceptWebSocketAsync(null);
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -100,7 +100,7 @@ public class OverlayServer
|
||||
logger.Error(e);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var webSocket = webSocketContext.WebSocket;
|
||||
try
|
||||
{
|
||||
@@ -117,7 +117,7 @@ public class OverlayServer
|
||||
var message = JsonSerializer.Deserialize<OverlayMessage>(text);
|
||||
HandleMessage(message);
|
||||
continue;
|
||||
|
||||
|
||||
case WebSocketMessageType.Close:
|
||||
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
|
||||
break;
|
||||
@@ -157,7 +157,7 @@ public class OverlayServer
|
||||
MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame)
|
||||
MainForm.Instance.Browser.ExecuteScriptAsync("window?.$pinia?.vr.vrInit();");
|
||||
break;
|
||||
|
||||
|
||||
case OverlayMessageType.IsHmdAfk:
|
||||
var isHmdAfk = string.Equals(message.Data, "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (MainForm.Instance?.Browser != null && !MainForm.Instance.Browser.IsLoading && MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame)
|
||||
@@ -170,7 +170,7 @@ public class OverlayServer
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void SendMessage(OverlayMessage message)
|
||||
{
|
||||
lock (SendLock)
|
||||
@@ -191,12 +191,12 @@ public class OverlayServer
|
||||
public void UpdateVars(OverlayVars overlayVars)
|
||||
{
|
||||
_overlayVars = overlayVars;
|
||||
if (!IsConnected() && overlayVars.Active)
|
||||
if (!IsConnected() && (overlayVars.Active || Program.LaunchDebug))
|
||||
{
|
||||
OverlayManager.StartOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var helloMessage = new OverlayMessage
|
||||
{
|
||||
Type = OverlayMessageType.UpdateVars,
|
||||
|
||||
+5
-2
@@ -203,8 +203,11 @@ namespace VRCX
|
||||
}
|
||||
|
||||
logger.Fatal(e, "Unhandled Exception, program dying");
|
||||
MessageBox.Show(e.ToString(), "PLEASE REPORT IN https://vrcx.app/discord", MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
var result = MessageBox.Show(e.ToString(), $"{Version} crashed, open Discord for support?", MessageBoxButtons.YesNo, MessageBoxIcon.Error);
|
||||
if (result == DialogResult.Yes)
|
||||
{
|
||||
AppApiInstance.OpenLink("https://vrcx.app/discord");
|
||||
}
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,21 +93,21 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CefSharp.OffScreen.NETCore" Version="141.0.110" />
|
||||
<PackageReference Include="CefSharp.WinForms.NETCore" Version="141.0.110" />
|
||||
<PackageReference Include="CefSharp.OffScreen.NETCore" Version="146.0.70" />
|
||||
<PackageReference Include="CefSharp.WinForms.NETCore" Version="146.0.70" />
|
||||
<PackageReference Include="DiscordRichPresence" Version="1.6.1.70" />
|
||||
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="NLog" Version="6.0.7" />
|
||||
<PackageReference Include="Silk.NET.Direct3D.Compilers" Version="2.22.0" />
|
||||
<PackageReference Include="Silk.NET.Direct3D11" Version="2.22.0" />
|
||||
<PackageReference Include="Silk.NET.DXGI" Version="2.22.0" />
|
||||
<PackageReference Include="Silk.NET.Windowing" Version="2.22.0" />
|
||||
<PackageReference Include="Silk.NET.Direct3D.Compilers" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.Direct3D11" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.DXGI" Version="2.23.0" />
|
||||
<PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
|
||||
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" />
|
||||
<PackageReference Include="System.Data.SQLite" Version="2.0.2" />
|
||||
<PackageReference Include="System.Management" Version="10.0.1" />
|
||||
<PackageReference Include="System.Management" Version="10.0.2" />
|
||||
<PackageReference Include="Websocket.Client" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.JavaScript.NodeApi" Version="0.9.18" />
|
||||
<PackageReference Include="Microsoft.JavaScript.NodeApi.Generator" Version="0.9.18" />
|
||||
<PackageReference Include="Microsoft.JavaScript.NodeApi" Version="0.9.19" />
|
||||
<PackageReference Include="Microsoft.JavaScript.NodeApi.Generator" Version="0.9.19" />
|
||||
<PackageReference Include="DiscordRichPresence" Version="1.6.1.70" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="NLog" Version="6.0.7" />
|
||||
@@ -86,7 +86,7 @@
|
||||
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
|
||||
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" />
|
||||
<PackageReference Include="System.Data.SQLite" Version="1.0.119" /> <!--DO NOT UPGRADE-->
|
||||
<PackageReference Include="System.Management" Version="10.0.1" />
|
||||
<PackageReference Include="System.Management" Version="10.0.2" />
|
||||
<PackageReference Include="Websocket.Client" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+9
-2
@@ -143,6 +143,7 @@ namespace VRCX
|
||||
#endif
|
||||
CookieContainer = new CookieContainer();
|
||||
InitializeHttpClient();
|
||||
_cookieDirty = true;
|
||||
SaveCookies();
|
||||
}
|
||||
|
||||
@@ -247,9 +248,15 @@ namespace VRCX
|
||||
|
||||
public void SetCookies(string cookies)
|
||||
{
|
||||
using (var stream = new MemoryStream(Convert.FromBase64String(cookies)))
|
||||
try
|
||||
{
|
||||
CookieContainer.Add(System.Text.Json.JsonSerializer.Deserialize<CookieCollection>(stream));
|
||||
using var stream = new MemoryStream(Convert.FromBase64String(cookies));
|
||||
var data = System.Text.Json.JsonSerializer.Deserialize<CookieCollection>(stream);
|
||||
CookieContainer.Add(data);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.Error($"Failed to set cookies: {e.Message}");
|
||||
}
|
||||
|
||||
_cookieDirty = true; // force cookies to be saved for lastUserLoggedIn
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
;--------------------------------
|
||||
;General
|
||||
|
||||
SetCompressor /SOLID lzma
|
||||
SetCompressorDictSize 16
|
||||
Unicode True
|
||||
Name "VRCX"
|
||||
OutFile "VRCX_Setup.exe"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://github.com/vrcx-team/VRCX/actions/workflows/github_actions.yml)
|
||||
[](https://vrcx.app/discord)
|
||||
|
||||
| **English** | [Français](./README/README.fr.md) | [日本語](./README/README.jp.md) | [简体中文](./README/README.zh_CN.md) | [Italiano](./README/README.it.md) | [Русский](./README/README.ru_RU.md) | [Español](./README/README.es.md) | [Polski](./README/README.pl.md) | [ภาษาไทย](./README/README.th.md)
|
||||
| **English** | [Français](./README/README.fr.md) | [日本語](./README/README.jp.md) | [简体中文](./README/README.zh_CN.md) | [Italiano](./README/README.it.md) | [Русский](./README/README.ru_RU.md) | [Español](./README/README.es.md) | [Polski](./README/README.pl.md) | [ภาษาไทย](./README/README.th.md) | [Magyar](./README/README.hu.md)
|
||||
|
||||
VRCX is an assistant/companion application for VRChat that provides information about and helps you accomplish various things related to VRChat in a more convenient fashion than relying on the plain VRChat client (desktop or VR), or website alone. It also includes some other neat features outlined below.
|
||||
|
||||
@@ -26,37 +26,42 @@ Beta/nightly build available [here](https://vrcx.app/github/nightly) or in-app `
|
||||
<div align="left">
|
||||
|
||||
- :family: Friend, world, and avatar list management
|
||||
- Manage your friends list, world/group/avatar lists outside of VRChat.
|
||||
- Monitor the world/avatar activity of your friends and check their online status.
|
||||
- Keep track of when you first added them and when you last saw them.
|
||||
- See how much time you've spent together in worlds and how many times.
|
||||
- Keep track of friend name changes.
|
||||
- Save notes to help remember how you met.
|
||||
- :electric_plug: Automatically launch apps when you start VRChat
|
||||
- You can configure VRCX to launch other apps when you start VRChat.
|
||||
- For example, you could have VRCX launch an OSC app or a voice changer app when VRChat opens up.
|
||||
- :mag: Search for avatars, users, worlds, and groups
|
||||
- :earth_americas: Build a local, unrestricted world favorites list
|
||||
- Manage your friends list, world/group/avatar lists outside of VRChat.
|
||||
- Monitor the activity of your friends and track their online status, locations, and avatars.
|
||||
- Track friendship history including add dates, time spent together, and name changes.
|
||||
- Save notes and memos to help remember how you met.
|
||||
- :bar_chart: Customizable Dashboard with widgets
|
||||
- Build personalized multi-panel layouts with Feed, GameLog, and Instance widgets.
|
||||
- Create multiple dashboards, each with configurable event filters and column visibility.
|
||||
- :mag: Powerful search across all entities
|
||||
- Search for users, worlds, avatars, and groups, or paste IDs and URLs for direct access.
|
||||
- Quick Search provides instant client-side fuzzy search across your friends, avatars, worlds, and groups.
|
||||
- :chart_with_upwards_trend: Activity Heatmap
|
||||
- Visualize a user's online activity patterns with a day-of-week × hour-of-day heatmap, including peak stats.
|
||||
- :camera: Store world data in the pictures you take in-game, so you can remember that one world you took those cool pictures in like... 6 months ago!
|
||||
- :bell: Monitor/respond to notifications
|
||||
- You can send/receive invites and friend requests from VRCX as well as see the instance info of invites that you receive.
|
||||
- You can send/receive invites and friend requests from VRCX as well as see the instance info of invites that you receive.
|
||||
- :scroll: See stats/players for your current instance
|
||||
- :tv: See the links to videos and that are playing in the world you're in, as well as various other logged data.
|
||||
- :tv: See the links to videos that are playing in the world you're in, as well as various other logged data.
|
||||
- :performing_arts: Social Status Presets
|
||||
- Save and quickly apply status + status description combinations from the sidebar or user dialog.
|
||||
- :rotating_light: VRChat Server Status
|
||||
- A status bar indicator and login page alert inform you of VRChat server issues and outages in real time.
|
||||
- :bar_chart: Improved Discord Rich Presence
|
||||
- You can optionally display more information about your current instance in Discord.
|
||||
- World integration for popular worlds like Popcorn Palace, PyPyDance, VRDancing and LSMedia.
|
||||
- This includes the world thumbnail, name, instance ID, and player count, depending on your settings and whether the lobby is private. You can also add a join button for public lobbies!
|
||||
- Display detailed instance information in Discord, including world thumbnail, name, player count, and a join button for public lobbies.
|
||||
- :crystal_ball: VR Overlay with configurable live feed of all supported events/notifications
|
||||
- :outbox_tray: Upload avatar/world images without Unity
|
||||
- :page_facing_up: Manage and edit uploaded avatar/world details without Unity
|
||||
- :outbox_tray: Upload and manage avatar/world images and details without Unity
|
||||
- :electric_plug: Automatically launch apps when you start VRChat
|
||||
- :skull: Automatically restart and join last instance when VRC crashes
|
||||
- :left_right_arrow: Export/import favorite groups
|
||||
- :left_right_arrow: Export/import data
|
||||
- Export friends list, avatar list, Discord names, notes, and favorite groups. Import favorite groups and group moderation bans.
|
||||
|
||||
## Miscellanous
|
||||
## Miscellaneous
|
||||
|
||||
- Want a new look for VRCX? Check out [Themes](https://github.com/vrcx-team/VRCX/wiki/Themes)
|
||||
- See [Building from source](https://github.com/vrcx-team/VRCX/wiki/Building-from-source) for instructions on how to build VRCX from source.
|
||||
- For a guide on how to run VRCX on linux, see [here](https://github.com/vrcx-team/VRCX/wiki/Running-VRCX-on-Linux)
|
||||
- For a guide on how to run VRCX on Linux, see [here](https://github.com/vrcx-team/VRCX/wiki/Running-VRCX-on-Linux)
|
||||
- Interested in contributing? See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for guidelines.
|
||||
|
||||
# Screenshots
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<div align="center">
|
||||
|
||||
# <img src="https://raw.githubusercontent.com/vrcx-team/VRCX/master/images/VRCX.ico" width="64" height="64"> </img> VRCX
|
||||
|
||||
[](https://github.com/vrcx-team/VRCX/releases/latest)
|
||||
[](https://github.com/vrcx-team/VRCX/releases/latest)
|
||||
[](https://github.com/vrcx-team/VRCX/actions/workflows/github_actions.yml)
|
||||
[](https://vrcx.app/discord)
|
||||
|
||||
| [English](/README.md) |
|
||||
|
||||
A VRCX egy kiegészítő program VRChat-hez, ami segít különféle dolgokat elintézni a játékkal kapcsolatban – sokkal kényelmesebben, mint a sima VRChat kliens (asztali vagy VR módban) vagy a weboldaluk. Ráadásul van benne még néhány klassz extra funkció is, amiket lejjebb részletezünk.
|
||||
|
||||
# Hogyan kezdj hozzá
|
||||
|
||||
<div align="center">
|
||||
|
||||
Töltsd le és telepítsd a legfrissebb telepítőt (`VRCX_Setup.exe`) [innen](https://github.com/vrcx-team/VRCX/releases/latest).
|
||||
|
||||
# Mire jó?
|
||||
|
||||
<div align="left">
|
||||
|
||||
- :family: Barátok, világok és avatarok kezelése
|
||||
- Kezeld a baráti listádat, a kedvenc világjaidat, csoportjaidat és avatárjaidat anélkül, hogy megnyitnád a VRChat-et.
|
||||
- Kövesd nyomon, hogy a barátaid mit csinálnak éppen: hol járnak és milyen avatárt viselnek.
|
||||
- Láthatod, mikor adtad hozzá őket és mikor láttad őket utoljára.
|
||||
- Megnézheted, mennyi időt töltöttetek együtt különböző világokban és hányszor találkoztatok.
|
||||
- Követi, ha valaki nevet vált.
|
||||
- Írj magadnak feljegyzéseket, hogy emlékezz, hogyan találkoztatok.
|
||||
- :electric_plug: Programok automatikus indítása VRChat mellé
|
||||
- Beállíthatod, hogy a VRCX elindítson más programokat is, amikor megnyitod a VRChat-et.
|
||||
- Például automatikusan elindíthat egy OSC-alkalmazást vagy hangváltót, amikor elindítod a játékot.
|
||||
- :mag: Avatárok, felhasználók, világok és csoportok keresése
|
||||
- :earth_americas: Saját kedvencek lista, ami nincs korlátozva úgy, mint a játékban
|
||||
- :camera: Elmenti a világ adatait a játékban készített képekbe, hogy visszanézhess és megtaláld azt a helyet, ahol azokat a menő fotókat csináltad... mondjuk 6 hónappal ezelőtt!
|
||||
- :bell: Értesítések kezelése
|
||||
- Meghívókat és barátkéréseket küldhetsz és fogadhatsz közvetlenül a VRCX-ből, és láthatod a kapott meghívók részleteit is.
|
||||
- :scroll: Megnézheted az aktuális szobád (instance) statisztikáit és a jelenlévő játékosokat
|
||||
- :tv: Láthatod a linkeket azokhoz a videókhoz, amik éppen mennek a világban, ahol vagy, meg egyéb naplózott adatokat.
|
||||
- :bar_chart: Fejlettebb Discord-jelenlét (Rich Presence)
|
||||
- Opcionálisan több információt is megjeleníthetsz a Discord-profilodon arról, hogy éppen mit csinálsz VRChat-ben.
|
||||
- Támogatja a népszerű világokat, mint a PyPyDance, LSMedia, Movies&Chill és VRDancing.
|
||||
- Megjelenítheti a világ képét, nevét, a szoba azonosítóját és a játékosok számát – attól függően, hogyan állítod be, és hogy a szoba nyilvános-e. Nyilvános szobákhoz még egy „Csatlakozás" gombot is hozzáadhatsz!
|
||||
- :crystal_ball: VR-overlay, ami élőben mutat mindenfajta eseményt és értesítést, amit te állítasz be.
|
||||
- :outbox_tray: Avatar- és világképek feltöltése Unity nélkül
|
||||
- :page_facing_up: A feltöltött avatarok és világok adatainak szerkesztése Unity nélkül
|
||||
- :skull: Ha a VRChat összeomlik, a VRCX automatikusan újraindítja és visszavisz az előző szobádba.
|
||||
- :left_right_arrow: Kedvenc csoportok exportálása és importálása
|
||||
|
||||
## Egyéb dolgok
|
||||
|
||||
- Más kinézetet szeretnél a VRCX-nek? Nézd meg a [Témákat](https://github.com/vrcx-team/VRCX/wiki/Themes)!
|
||||
- Ha magad szeretnéd lefordítani a forráskódból, lásd: [Fordítás forrásból](https://github.com/vrcx-team/VRCX/wiki/Building-from-source).
|
||||
- Ha Linuxon szeretnéd futtatni a VRCX-et, erre [itt](https://github.com/vrcx-team/VRCX/wiki/Running-VRCX-on-Linux) találsz útmutatót.
|
||||
|
||||
# Képernyőképek
|
||||
|
||||
<div align="center">
|
||||
|
||||
<h3>Bejelentkezés</h3>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251994190-5e6a961e-b2fe-4d3b-bf66-455d8626b8bf.png" alt="bejelentkezés"></td>
|
||||
<td align="center"><img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251994414-a21faf59-6199-45de-94e7-a093a6b8c0ac.png" alt="2fa"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Hírfolyam</h3>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251987020-9839a2c9-47db-4271-b1bf-8e07669a7056.png" alt="feed">
|
||||
|
||||
<h3>Játéknapló</h3>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251987498-b82266ed-131d-42ad-be2f-b167f24acf9f.png" alt="játéknapló">
|
||||
|
||||
<h3>Felhasználói adatok</h3>
|
||||
|
||||
<h4>Én</h4>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251990237-0c863d27-141c-4447-82de-4279ab8973ea.png" alt="én">
|
||||
|
||||
<h4>Barát</h4>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251989666-8f918786-e632-451d-be29-f92d2c681b80.png" alt="barát">
|
||||
|
||||
<h3>Világ</h3>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251991003-37a986bb-470c-442b-8ada-31918f7b2017.png" alt="szoba"></td>
|
||||
<td align="center"><img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251991217-0d40846f-ac08-48c0-8e4d-18c35fe0999b.png" alt="infó"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Kedvencek</h3>
|
||||
|
||||
<h4>Barátok</h4>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251992424-ba406d0f-787e-4e2d-89bd-4caa0a05d31f.png" alt="barát">
|
||||
|
||||
<h4>Világok</h4>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251992950-8f2c6cdc-dc9a-4a60-b59f-9fa80d071359.png" alt="világ">
|
||||
|
||||
<h4>Avatárok</h4>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251993408-66d11100-15a8-484f-b9fd-82be1516c9be.png" alt="avatár">
|
||||
|
||||
<h3>Baráti napló</h3>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251993741-e2033095-4ceb-4552-8b79-9285325c1e49.png" alt="baráti napló">
|
||||
|
||||
<h3>Discord-jelenlét</h3>
|
||||
|
||||
<img src="https://github-production-user-asset-6210df.s3.amazonaws.com/82102170/251997318-5a71249c-59fc-4ad6-9194-d6b1d4165600.png" alt="discord">
|
||||
|
||||
</div>
|
||||
|
||||
## A VRCX megsérti a VRChat szabályzatát?
|
||||
|
||||
**Nem.**
|
||||
|
||||
A VRCX egy külső program, ami a VRChat nyilvános API-ját használja a funkcióihoz.
|
||||
|
||||
Semmilyen módon nem nyúl bele a játékba, csak felelősségteljesen használja az API-t. Nem mod, nem csaló program, és semmi más ilyesmi.
|
||||
|
||||
Ha meg szeretnéd nézni, mit gondol a VRChat az API-használatról, keresd fel a VRChat Discord szerver #faq csatornáját.
|
||||
|
||||
---
|
||||
|
||||
A VRCX-et nem támogatja a VRChat, és nem képviseli a VRChat vagy az abban dolgozók véleményét. A VRChat és minden kapcsolódó márkanév a VRChat Inc. tulajdona. VRChat © VRChat Inc.
|
||||
@@ -0,0 +1,456 @@
|
||||
/* global __dirname, require */
|
||||
// generate-third-party-licenses.js
|
||||
// use by frontend open source software notice dialog
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const frontendLicensePath = path.join(
|
||||
rootDir,
|
||||
'build',
|
||||
'html',
|
||||
'.vite',
|
||||
'license.md'
|
||||
);
|
||||
const outputDir = path.join(rootDir, 'build', 'html', 'licenses');
|
||||
const outputManifestPath = path.join(outputDir, 'third-party-licenses.json');
|
||||
const outputNoticePath = path.join(outputDir, 'THIRD_PARTY_NOTICES.txt');
|
||||
const dotnetDir = path.join(rootDir, 'Dotnet');
|
||||
const nugetCacheDir =
|
||||
process.env.NUGET_PACKAGES || path.join(os.homedir(), '.nuget', 'packages');
|
||||
const overridesPath = path.join(__dirname, 'licenses', 'nuget-overrides.json');
|
||||
|
||||
const nugetOverrides = JSON.parse(fs.readFileSync(overridesPath, 'utf8'));
|
||||
|
||||
function ensureDirectory(directoryPath) {
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
}
|
||||
|
||||
function readFileIfExists(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value) {
|
||||
return value?.replace(/\r\n/g, '\n').trim() || '';
|
||||
}
|
||||
|
||||
function sanitizeId(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function extractXmlTagValue(xml, tagName) {
|
||||
const match = xml.match(
|
||||
new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i')
|
||||
);
|
||||
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
function extractXmlSelfClosingTagAttribute(xml, tagName, attributeName) {
|
||||
const match = xml.match(
|
||||
new RegExp(
|
||||
`<${tagName}[^>]*${attributeName}="([^"]+)"[^>]*>(?:[\\s\\S]*?)<\\/${tagName}>`,
|
||||
'i'
|
||||
)
|
||||
);
|
||||
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
function extractRepositoryUrl(xml) {
|
||||
const match = xml.match(/<repository[^>]*url="([^"]+)"/i);
|
||||
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
function findFirstExistingFile(filePaths) {
|
||||
return filePaths.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
|
||||
function findPackageLicenseFile(packageDir) {
|
||||
if (!fs.existsSync(packageDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stack = [packageDir];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const currentDir = stack.pop();
|
||||
const dirEntries = fs.readdirSync(currentDir, { withFileTypes: true });
|
||||
|
||||
for (const dirEntry of dirEntries) {
|
||||
const fullPath = path.join(currentDir, dirEntry.name);
|
||||
|
||||
if (dirEntry.isDirectory()) {
|
||||
if (!fullPath.includes(`${path.sep}tools${path.sep}`)) {
|
||||
stack.push(fullPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
/^(license|licence|notice|copying)(\.[^.]+)?$/i.test(
|
||||
dirEntry.name
|
||||
)
|
||||
) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseFrontendLicenses(markdown) {
|
||||
const normalized = normalizeWhitespace(markdown);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sections = normalized.split(/\n(?=## )/g).slice(1);
|
||||
|
||||
return sections
|
||||
.map((section) => {
|
||||
const [headerLine, ...bodyLines] = section.split('\n');
|
||||
const headerMatch = headerLine.match(
|
||||
/^##\s+(.+?)\s+-\s+(.+?)\s+\((.+?)\)$/
|
||||
);
|
||||
|
||||
if (!headerMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, name, version, license] = headerMatch;
|
||||
const noticeText = normalizeWhitespace(bodyLines.join('\n'));
|
||||
|
||||
return {
|
||||
id: `frontend-${sanitizeId(`${name}-${version}`)}`,
|
||||
name,
|
||||
version,
|
||||
license,
|
||||
sourceType: 'frontend',
|
||||
sourceLabel: 'Frontend bundle',
|
||||
noticeText,
|
||||
needsReview: !license && !noticeText
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function parseCsprojPackageReferences(csprojText) {
|
||||
return [
|
||||
...csprojText.matchAll(
|
||||
/<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)"/g
|
||||
)
|
||||
].map(([, name, version]) => ({
|
||||
name,
|
||||
version,
|
||||
sourceType: 'dotnet'
|
||||
}));
|
||||
}
|
||||
|
||||
function parseCsprojBinaryReferences(csprojText) {
|
||||
const binaryEntries = [];
|
||||
|
||||
for (const [, name, hintPath] of csprojText.matchAll(
|
||||
/<Reference\s+Include="([^"]+)">[\s\S]*?<HintPath>([^<]+)<\/HintPath>[\s\S]*?<\/Reference>/g
|
||||
)) {
|
||||
binaryEntries.push({
|
||||
name,
|
||||
version: '',
|
||||
sourceType: 'native',
|
||||
filePath: hintPath.replaceAll('\\', '/')
|
||||
});
|
||||
}
|
||||
|
||||
for (const [, includePath] of csprojText.matchAll(
|
||||
/<None\s+Include="([^"]*libs[^"]+\.(?:dll|so|dylib))">/g
|
||||
)) {
|
||||
const normalizedPath = includePath.replaceAll('\\', '/');
|
||||
const fileName = path.basename(normalizedPath);
|
||||
const overrideName =
|
||||
fileName === 'openvr_api.dll' ? 'OpenVR SDK' : fileName;
|
||||
|
||||
binaryEntries.push({
|
||||
name: overrideName,
|
||||
version: '',
|
||||
sourceType: 'native',
|
||||
filePath: normalizedPath
|
||||
});
|
||||
}
|
||||
|
||||
return binaryEntries;
|
||||
}
|
||||
|
||||
function parseAssetsLibraries(projectAssetsPath) {
|
||||
const assetsRaw = readFileIfExists(projectAssetsPath);
|
||||
if (!assetsRaw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const assets = JSON.parse(assetsRaw);
|
||||
const libraries = assets.libraries || {};
|
||||
|
||||
return Object.keys(libraries)
|
||||
.filter(
|
||||
(libraryKey) =>
|
||||
!libraries[libraryKey]?.type ||
|
||||
libraries[libraryKey].type === 'package'
|
||||
)
|
||||
.map((libraryKey) => {
|
||||
const lastSlashIndex = libraryKey.lastIndexOf('/');
|
||||
|
||||
return {
|
||||
name: libraryKey.slice(0, lastSlashIndex),
|
||||
version: libraryKey.slice(lastSlashIndex + 1),
|
||||
sourceType: 'dotnet'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDotnetEntries(csprojFiles) {
|
||||
const collectedEntries = new Map();
|
||||
|
||||
for (const csprojFile of csprojFiles) {
|
||||
const projectName = path.basename(csprojFile, '.csproj');
|
||||
const csprojText = fs.readFileSync(csprojFile, 'utf8');
|
||||
const assetEntries = parseAssetsLibraries(
|
||||
path.join(path.dirname(csprojFile), 'obj', 'project.assets.json')
|
||||
);
|
||||
const packageEntries =
|
||||
assetEntries.length > 0
|
||||
? assetEntries
|
||||
: parseCsprojPackageReferences(csprojText);
|
||||
const binaryEntries = parseCsprojBinaryReferences(csprojText);
|
||||
|
||||
for (const entry of [...packageEntries, ...binaryEntries]) {
|
||||
const key = `${entry.sourceType}:${entry.name}:${entry.version}`;
|
||||
const existingEntry = collectedEntries.get(key) || {
|
||||
...entry,
|
||||
projects: []
|
||||
};
|
||||
|
||||
existingEntry.projects = [
|
||||
...new Set([...existingEntry.projects, projectName])
|
||||
].sort();
|
||||
collectedEntries.set(key, existingEntry);
|
||||
}
|
||||
}
|
||||
|
||||
return [...collectedEntries.values()].sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNugetMetadata(name, version) {
|
||||
const packageDir = path.join(nugetCacheDir, name.toLowerCase(), version);
|
||||
const nuspecPath =
|
||||
findFirstExistingFile([
|
||||
path.join(packageDir, `${name.toLowerCase()}.nuspec`),
|
||||
...(fs.existsSync(packageDir)
|
||||
? fs
|
||||
.readdirSync(packageDir)
|
||||
.filter((fileName) => fileName.endsWith('.nuspec'))
|
||||
.map((fileName) => path.join(packageDir, fileName))
|
||||
: [])
|
||||
]) || null;
|
||||
|
||||
const override = nugetOverrides[name] || {};
|
||||
const metadata = {
|
||||
license: override.license || '',
|
||||
licenseUrl: override.licenseUrl || '',
|
||||
projectUrl: override.projectUrl || '',
|
||||
noticeText: normalizeWhitespace(override.noticeText),
|
||||
needsReview: false
|
||||
};
|
||||
|
||||
if (!nuspecPath) {
|
||||
metadata.needsReview = !metadata.license && !metadata.noticeText;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const nuspecText = fs.readFileSync(nuspecPath, 'utf8');
|
||||
const licenseExpression =
|
||||
extractXmlSelfClosingTagAttribute(nuspecText, 'license', 'type') ===
|
||||
'expression'
|
||||
? extractXmlTagValue(nuspecText, 'license')
|
||||
: '';
|
||||
const licenseFilePath =
|
||||
extractXmlSelfClosingTagAttribute(nuspecText, 'license', 'type') ===
|
||||
'file'
|
||||
? extractXmlTagValue(nuspecText, 'license')
|
||||
: '';
|
||||
|
||||
metadata.license ||= licenseExpression;
|
||||
metadata.licenseUrl ||= extractXmlTagValue(nuspecText, 'licenseUrl');
|
||||
metadata.projectUrl ||=
|
||||
extractXmlTagValue(nuspecText, 'projectUrl') ||
|
||||
extractRepositoryUrl(nuspecText);
|
||||
|
||||
if (!metadata.noticeText) {
|
||||
const embeddedLicensePath = licenseFilePath
|
||||
? path.join(packageDir, licenseFilePath.replaceAll('\\', path.sep))
|
||||
: null;
|
||||
const discoveredLicensePath = findPackageLicenseFile(packageDir);
|
||||
const resolvedLicensePath = findFirstExistingFile(
|
||||
[embeddedLicensePath, discoveredLicensePath].filter(Boolean)
|
||||
);
|
||||
|
||||
metadata.noticeText = normalizeWhitespace(
|
||||
readFileIfExists(resolvedLicensePath)
|
||||
);
|
||||
}
|
||||
|
||||
metadata.needsReview = !metadata.license && !metadata.noticeText;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function enrichDotnetEntries(entries) {
|
||||
return entries.map((entry) => {
|
||||
const override = nugetOverrides[entry.name] || {};
|
||||
|
||||
if (entry.sourceType === 'native') {
|
||||
return {
|
||||
id: `native-${sanitizeId(entry.name)}`,
|
||||
...entry,
|
||||
license: override.license || 'Unknown',
|
||||
licenseUrl: override.licenseUrl || '',
|
||||
projectUrl: override.projectUrl || '',
|
||||
noticeText: normalizeWhitespace(override.noticeText),
|
||||
sourceLabel: 'Bundled native/.NET component',
|
||||
needsReview: !override.license && !override.noticeText
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = entry.version
|
||||
? resolveNugetMetadata(entry.name, entry.version)
|
||||
: {
|
||||
license: override.license || '',
|
||||
licenseUrl: override.licenseUrl || '',
|
||||
projectUrl: override.projectUrl || '',
|
||||
noticeText: normalizeWhitespace(override.noticeText),
|
||||
needsReview: !override.license && !override.noticeText
|
||||
};
|
||||
|
||||
return {
|
||||
id: `${entry.sourceType}-${sanitizeId(`${entry.name}-${entry.version}`)}`,
|
||||
...entry,
|
||||
license: metadata.license || 'Unknown',
|
||||
licenseUrl: metadata.licenseUrl || '',
|
||||
projectUrl: metadata.projectUrl || '',
|
||||
noticeText: metadata.noticeText,
|
||||
sourceLabel: 'Bundled .NET/native backend component',
|
||||
needsReview: metadata.needsReview
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createThirdPartyNoticeText(frontendLicenseMarkdown, entries) {
|
||||
const lines = [
|
||||
'VRCX Third-Party Notices',
|
||||
'',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
'',
|
||||
'========================================',
|
||||
'Frontend bundled dependencies',
|
||||
'========================================',
|
||||
'',
|
||||
normalizeWhitespace(frontendLicenseMarkdown) ||
|
||||
'No frontend license manifest was available.',
|
||||
'',
|
||||
'',
|
||||
'========================================',
|
||||
'.NET and native bundled components',
|
||||
'========================================',
|
||||
''
|
||||
];
|
||||
|
||||
for (const entry of entries.filter(
|
||||
(item) => item.sourceType !== 'frontend'
|
||||
)) {
|
||||
lines.push(
|
||||
`${entry.name}${entry.version ? ` - ${entry.version}` : ''} (${entry.license})`
|
||||
);
|
||||
lines.push(`Source: ${entry.sourceLabel}`);
|
||||
|
||||
if (entry.projects?.length) {
|
||||
lines.push(`Used by: ${entry.projects.join(', ')}`);
|
||||
}
|
||||
|
||||
if (entry.projectUrl) {
|
||||
lines.push(`Project URL: ${entry.projectUrl}`);
|
||||
}
|
||||
|
||||
if (entry.licenseUrl) {
|
||||
lines.push(`License URL: ${entry.licenseUrl}`);
|
||||
}
|
||||
|
||||
if (entry.filePath) {
|
||||
lines.push(`Bundled file: ${entry.filePath}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
if (entry.noticeText) {
|
||||
lines.push(entry.noticeText);
|
||||
} else {
|
||||
lines.push(
|
||||
'No local license text was available during generation. Review this component before release.'
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('----------------------------------------');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return `${lines.join('\n').trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
ensureDirectory(outputDir);
|
||||
|
||||
const frontendLicenseMarkdown = readFileIfExists(frontendLicensePath) || '';
|
||||
const frontendEntries = parseFrontendLicenses(frontendLicenseMarkdown);
|
||||
const csprojFiles = fs
|
||||
.readdirSync(dotnetDir)
|
||||
.filter((fileName) => fileName.endsWith('.csproj'))
|
||||
.map((fileName) => path.join(dotnetDir, fileName))
|
||||
.concat(path.join(dotnetDir, 'DBMerger', 'DBMerger.csproj'))
|
||||
.filter(
|
||||
(filePath, index, filePaths) =>
|
||||
filePaths.indexOf(filePath) === index && fs.existsSync(filePath)
|
||||
);
|
||||
|
||||
const dotnetEntries = enrichDotnetEntries(mergeDotnetEntries(csprojFiles));
|
||||
const manifest = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
noticePath: 'licenses/THIRD_PARTY_NOTICES.txt',
|
||||
entries: [...frontendEntries, ...dotnetEntries]
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputManifestPath, JSON.stringify(manifest, null, 4));
|
||||
fs.writeFileSync(
|
||||
outputNoticePath,
|
||||
createThirdPartyNoticeText(frontendLicenseMarkdown, manifest.entries)
|
||||
);
|
||||
|
||||
const reviewCount = manifest.entries.filter(
|
||||
(entry) => entry.needsReview
|
||||
).length;
|
||||
console.log(
|
||||
`Generated third-party license manifest with ${manifest.entries.length} entries (${reviewCount} requiring review).`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"CefSharp.OffScreen.NETCore": {
|
||||
"license": "BSD-3-Clause",
|
||||
"projectUrl": "https://github.com/cefsharp/CefSharp",
|
||||
"noticeText": "// Copyright © The CefSharp Authors. All rights reserved.\n//\n// Redistribution and use in source and binary forms, with or without\n// modification, are permitted provided that the following conditions are\n// met:\n//\n// * Redistributions of source code must retain the above copyright\n// notice, this list of conditions and the following disclaimer.\n//\n// * Redistributions in binary form must reproduce the above\n// copyright notice, this list of conditions and the following disclaimer\n// in the documentation and/or other materials provided with the\n// distribution.\n//\n// * Neither the name of Google Inc. nor the name Chromium Embedded\n// Framework nor the name CefSharp nor the names of its contributors\n// may be used to endorse or promote products derived from this software\n// without specific prior written permission.\n//\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n// \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\n// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\n// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\n// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\n// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\n// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\n// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\n// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
|
||||
},
|
||||
"CefSharp.WinForms.NETCore": {
|
||||
"license": "BSD-3-Clause",
|
||||
"projectUrl": "https://github.com/cefsharp/CefSharp",
|
||||
"noticeText": "// Copyright © The CefSharp Authors. All rights reserved.\n//\n// Redistribution and use in source and binary forms, with or without\n// modification, are permitted provided that the following conditions are\n// met:\n//\n// * Redistributions of source code must retain the above copyright\n// notice, this list of conditions and the following disclaimer.\n//\n// * Redistributions in binary form must reproduce the above\n// copyright notice, this list of conditions and the following disclaimer\n// in the documentation and/or other materials provided with the\n// distribution.\n//\n// * Neither the name of Google Inc. nor the name Chromium Embedded\n// Framework nor the name CefSharp nor the names of its contributors\n// may be used to endorse or promote products derived from this software\n// without specific prior written permission.\n//\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n// \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\n// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\n// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\n// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\n// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\n// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\n// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\n// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
|
||||
},
|
||||
"DiscordRichPresence": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://github.com/Lachee/discord-rpc-csharp",
|
||||
"noticeText": "MIT License\n\nCopyright (c) 2018 Lachee\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
|
||||
},
|
||||
"Newtonsoft.Json": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://github.com/JamesNK/Newtonsoft.Json",
|
||||
"noticeText": "The MIT License (MIT)\n\nCopyright (c) 2007 James Newton-King\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
|
||||
},
|
||||
"NLog": {
|
||||
"license": "BSD-3-Clause",
|
||||
"projectUrl": "https://github.com/NLog/NLog",
|
||||
"noticeText": "BSD 3-Clause License\n\nCopyright (c) 2004-2024 Jaroslaw Kowalski <jaak@jkowalski.net>, Kim Christensen, Julian Verdurmen\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
|
||||
},
|
||||
"SixLabors.ImageSharp": {
|
||||
"license": "Apache-2.0 OR Six Labors Split License",
|
||||
"projectUrl": "https://github.com/SixLabors/ImageSharp",
|
||||
"noticeText": "Apache License 2.0\n\nSix Labors Split License\nVersion 1.0, June 2022\nCopyright (c) Six Labors\n\nWorks in Source or Object form are split licensed and may be licensed under the Apache License, Version 2.0 or a Six Labors Commercial Use License.\n\nWorks in Source or Object form are licensed to You under the Apache License, Version 2.0 if:\n- You are consuming the Work in software licensed under an Open Source or Source Available license.\n- You are consuming the Work as a Transitive Package Dependency.\n- You are consuming the Work as a Direct Package Dependency as a for-profit company or individual with less than 1M USD annual gross revenue.\n- You are consuming the Work as a Direct Package Dependency as a non-profit organization or registered charity.\n\nFor all other scenarios, Works in Source or Object form are licensed to You under the Six Labors Commercial License."
|
||||
},
|
||||
"SixLabors.ImageSharp.Drawing": {
|
||||
"license": "Apache-2.0 OR Six Labors Split License",
|
||||
"projectUrl": "https://github.com/SixLabors/ImageSharp.Drawing",
|
||||
"noticeText": "Apache License 2.0\n\nSix Labors Split License\nVersion 1.0, June 2022\nCopyright (c) Six Labors\n\nWorks in Source or Object form are split licensed and may be licensed under the Apache License, Version 2.0 or a Six Labors Commercial Use License.\n\nWorks in Source or Object form are licensed to You under the Apache License, Version 2.0 if:\n- You are consuming the Work in software licensed under an Open Source or Source Available license.\n- You are consuming the Work as a Transitive Package Dependency.\n- You are consuming the Work as a Direct Package Dependency as a for-profit company or individual with less than 1M USD annual gross revenue.\n- You are consuming the Work as a Direct Package Dependency as a non-profit organization or registered charity.\n\nFor all other scenarios, Works in Source or Object form are licensed to You under the Six Labors Commercial License."
|
||||
},
|
||||
"OpenVR SDK": {
|
||||
"license": "BSD-3-Clause",
|
||||
"projectUrl": "https://github.com/ValveSoftware/openvr",
|
||||
"noticeText": "Copyright (c) 2015, Valve Corporation\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without modification,\nare permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\nlist of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\nthis list of conditions and the following disclaimer in the documentation and/or\nother materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its contributors\nmay be used to endorse or promote products derived from this software without\nspecific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND\nANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED\nWARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR\nANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES\n(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;\nLOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON\nANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
|
||||
},
|
||||
"Blake2Sharp": {
|
||||
"license": "CC0-1.0 OR OpenSSL OR Apache-2.0",
|
||||
"projectUrl": "https://github.com/BLAKE2/BLAKE2"
|
||||
},
|
||||
"librsync.net": {
|
||||
"license": "MIT"
|
||||
},
|
||||
"Microsoft.JavaScript.NodeApi": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://github.com/microsoft/node-api-dotnet"
|
||||
},
|
||||
"Microsoft.JavaScript.NodeApi.Generator": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://github.com/microsoft/node-api-dotnet"
|
||||
},
|
||||
"Microsoft.Toolkit.Uwp.Notifications": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://github.com/CommunityToolkit/WindowsCommunityToolkit"
|
||||
},
|
||||
"Silk.NET.Direct3D.Compilers": {
|
||||
"license": "MIT/X11",
|
||||
"projectUrl": "https://github.com/dotnet/Silk.NET"
|
||||
},
|
||||
"Silk.NET.Direct3D11": {
|
||||
"license": "MIT/X11",
|
||||
"projectUrl": "https://github.com/dotnet/Silk.NET"
|
||||
},
|
||||
"Silk.NET.DXGI": {
|
||||
"license": "MIT/X11",
|
||||
"projectUrl": "https://github.com/dotnet/Silk.NET"
|
||||
},
|
||||
"Silk.NET.Windowing": {
|
||||
"license": "MIT/X11",
|
||||
"projectUrl": "https://github.com/dotnet/Silk.NET"
|
||||
},
|
||||
"SourceGear.sqlite3": {
|
||||
"license": "Apache-2.0",
|
||||
"projectUrl": "https://github.com/ericsink/SQLitePCL.raw"
|
||||
},
|
||||
"sqlite-net-pcl": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://github.com/praeclarum/sqlite-net"
|
||||
},
|
||||
"System.CommandLine": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://github.com/dotnet/command-line-api"
|
||||
},
|
||||
"System.Data.SQLite": {
|
||||
"license": "Public Domain (with MS-PL components in LINQ/EF-related code)",
|
||||
"projectUrl": "https://system.data.sqlite.org/"
|
||||
},
|
||||
"System.Management": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://www.nuget.org/packages/System.Management"
|
||||
},
|
||||
"Websocket.Client": {
|
||||
"license": "MIT",
|
||||
"projectUrl": "https://github.com/Marfusios/websocket-client"
|
||||
}
|
||||
}
|
||||
+34
-21
@@ -1,12 +1,13 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import js from '@eslint/js';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import prettyImport from '@kamiya4047/eslint-plugin-pretty-import';
|
||||
import oxlint from 'eslint-plugin-oxlint';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: ['build/**', 'node_modules/**']
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,vue}'],
|
||||
plugins: { js },
|
||||
@@ -32,7 +33,8 @@ export default defineConfig([
|
||||
VERSION: 'readonly',
|
||||
NIGHTLY: 'readonly',
|
||||
webApiService: 'readonly',
|
||||
process: 'readonly'
|
||||
process: 'readonly',
|
||||
AppDebug: 'readonly'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -41,7 +43,8 @@ export default defineConfig([
|
||||
'**/webpack.*.js',
|
||||
'**/jest.config.js',
|
||||
'src-electron/*.js',
|
||||
'src/localization/*.js'
|
||||
'src/localization/*.js',
|
||||
'src/shared/utils/localizationHelperCLI.js'
|
||||
],
|
||||
languageOptions: {
|
||||
sourceType: 'commonjs',
|
||||
@@ -54,11 +57,15 @@ export default defineConfig([
|
||||
files: [
|
||||
'**/__tests__/**/*.{js,mjs,cjs,vue}',
|
||||
'**/*.spec.{js,mjs,cjs,vue}',
|
||||
'**/*.test.{js,mjs,cjs,vue}'
|
||||
'**/*.test.{js,mjs,cjs,vue}',
|
||||
'vitest.setup.js'
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest
|
||||
...globals.jest,
|
||||
...globals.node,
|
||||
vi: 'readonly',
|
||||
vitest: 'readonly'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -68,6 +75,25 @@ export default defineConfig([
|
||||
'no-unused-vars': 'warn',
|
||||
'no-case-declarations': 'off',
|
||||
'no-control-regex': 'warn',
|
||||
// Store boundary rule:
|
||||
// 1) Disallow `xxxStore.xxx = ...`
|
||||
// 2) Disallow `xxxStore.xxx++ / --`
|
||||
// Reason: prevent direct cross-store mutation and enforce owner-store actions.
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector:
|
||||
"AssignmentExpression[left.type='MemberExpression'][left.object.type='Identifier'][left.object.name=/Store$/]",
|
||||
message:
|
||||
'Do not mutate store state directly via *Store.* assignment. Use owner-store actions.'
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"UpdateExpression[argument.type='MemberExpression'][argument.object.type='Identifier'][argument.object.name=/Store$/]",
|
||||
message:
|
||||
'Do not mutate store state directly via *Store.* update operators. Use owner-store actions.'
|
||||
}
|
||||
],
|
||||
|
||||
'vue/no-mutating-props': 'warn',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
@@ -75,18 +101,5 @@ export default defineConfig([
|
||||
'vue/no-use-v-if-with-v-for': 'warn'
|
||||
}
|
||||
},
|
||||
{
|
||||
plugins: { 'pretty-import': prettyImport },
|
||||
rules: {
|
||||
'pretty-import/separate-type-imports': 'warn',
|
||||
'pretty-import/sort-import-groups': [
|
||||
'warn',
|
||||
{
|
||||
groupStyleImports: true
|
||||
}
|
||||
],
|
||||
'pretty-import/sort-import-names': 'warn'
|
||||
}
|
||||
},
|
||||
eslintPluginPrettierRecommended
|
||||
...oxlint.configs['flat/recommended']
|
||||
]);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['js', 'vue'],
|
||||
transform: {
|
||||
'^.+\\.js$': 'esbuild-jest'
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
},
|
||||
testMatch: ['<rootDir>/src/**/*.{test,spec}.js'],
|
||||
testPathIgnorePatterns: [],
|
||||
watchPathIgnorePatterns: ['<rootDir>/node_modules/', '<rootDir>/build/'],
|
||||
coverageReporters: ['text', 'text-summary'],
|
||||
collectCoverageFrom: [
|
||||
'src/shared/utils/**/*.js',
|
||||
'!src/shared/utils/**/*.test.js',
|
||||
'!src/shared/utils/**/__tests__/**'
|
||||
]
|
||||
};
|
||||
Generated
+3609
-9365
File diff suppressed because it is too large
Load Diff
+74
-58
@@ -1,99 +1,119 @@
|
||||
{
|
||||
"name": "VRCX",
|
||||
"description": "Friendship management tool for VRChat",
|
||||
"private": true,
|
||||
"description": "Friendship management tool for VRChat",
|
||||
"keywords": [
|
||||
"vrchat"
|
||||
],
|
||||
"homepage": "https://github.com/vrcx-team/VRCX#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vrcx-team/VRCX/issues"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "VRCX Team",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vrcx-team/VRCX.git"
|
||||
},
|
||||
"main": "src-electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "cross-env PLATFORM=windows vite serve src",
|
||||
"dev-linux": "cross-env PLATFORM=linux vite serve src",
|
||||
"dev:test": "concurrently \"npm run dev\" \"jest --watchAll\"",
|
||||
"localization": "node ./src/shared/utils/localizationHelperCLI.js",
|
||||
"test": "jest",
|
||||
"test:coverage": "jest --coverage",
|
||||
"prod": "cross-env PLATFORM=windows vite build src",
|
||||
"prod-linux": "cross-env PLATFORM=linux vite build src",
|
||||
"lint": "npm run lint:oxlint && npm run lint:eslint",
|
||||
"lint:eslint": "eslint .",
|
||||
"lint:oxlint": "oxlint .",
|
||||
"typecheck:js": "tsc -p tsconfig.checkjs.json --pretty false",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"build:licenses": "node ./build-scripts/generate-third-party-licenses.js",
|
||||
"prod": "cross-env PLATFORM=windows vite build src && npm run build:licenses",
|
||||
"prod-linux": "cross-env PLATFORM=linux vite build src && npm run build:licenses",
|
||||
"build-electron": "node ./src-electron/download-dotnet-runtime.js --arch=x64 && node ./src-electron/patch-package-version.js && electron-builder --x64 --publish never",
|
||||
"build-electron-arm64": "node ./src-electron/download-dotnet-runtime.js --arch=arm64 && node ./src-electron/patch-package-version.js && electron-builder --arm64 --publish never",
|
||||
"postbuild-electron": "node ./src-electron/patch-node-api-dotnet.js --arch=x64 && node ./src-electron/rename-builds.js --arch=x64",
|
||||
"postbuild-electron-arm64": "node ./src-electron/patch-node-api-dotnet.js --arch=arm64 && node ./src-electron/rename-builds.js --arch=arm64",
|
||||
"start-electron": "electron . --hot-reload"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vrcx-team/VRCX.git"
|
||||
"dependencies": {
|
||||
"hazardous": "^0.3.0",
|
||||
"node-api-dotnet": "^0.9.19"
|
||||
},
|
||||
"keywords": [
|
||||
"vrchat"
|
||||
],
|
||||
"author": "VRCX Team",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vrcx-team/VRCX/issues"
|
||||
},
|
||||
"homepage": "https://github.com/vrcx-team/VRCX#readme",
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@dnd-kit/vue": "^0.3.2",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-kr": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-sc": "^5.2.10",
|
||||
"@fontsource-variable/noto-sans-tc": "^5.2.10",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@kamiya4047/eslint-plugin-pretty-import": "^0.1.6",
|
||||
"@sentry/vite-plugin": "^4.6.2",
|
||||
"@sentry/vue": "^10.34.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@pinia/testing": "^1.0.3",
|
||||
"@sentry/vite-plugin": "^4.9.1",
|
||||
"@sentry/vue": "^10.46.0",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/vue-query": "^5.95.2",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.18",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^25.0.8",
|
||||
"@tanstack/vue-virtual": "^3.13.23",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.3",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"babel-runtime": "^6.26.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs": "^1.11.20",
|
||||
"echarts": "^6.0.0",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^26.4.0",
|
||||
"electron": "^40.8.5",
|
||||
"electron-builder": "^26.8.1",
|
||||
"embla-carousel-vue": "^8.6.0",
|
||||
"esbuild-jest": "^0.5.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-jsdoc": "^62.8.1",
|
||||
"eslint-plugin-oxlint": "^1.57.0",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.2.0",
|
||||
"lightningcss": "^1.30.2",
|
||||
"globals": "^17.4.0",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-communities-louvain": "^2.0.2",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"jsdom": "^28.1.0",
|
||||
"lightningcss": "^1.32.0",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"noty": "^3.2.0-beta-deprecated",
|
||||
"oxfmt": "^0.40.0",
|
||||
"oxlint": "^1.57.0",
|
||||
"pinia": "^3.0.4",
|
||||
"prettier": "^3.8.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"remixicon": "^4.8.0",
|
||||
"sass-embedded": "^1.97.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"reka-ui": "^2.9.2",
|
||||
"remixicon": "^4.9.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vite": "^7.3.1",
|
||||
"vue": "^3.5.26",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vite": "^8.0.3",
|
||||
"vitest": "^4.1.2",
|
||||
"vue": "^3.5.31",
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-input-otp": "^0.3.2",
|
||||
"vue-json-pretty": "^2.6.0",
|
||||
"vue-marquee-text-component": "^2.0.1",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-showdown": "^4.2.0",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"worker-timers": "^8.0.28",
|
||||
"worker-timers": "^8.0.31",
|
||||
"yargs": "^18.0.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.10.0",
|
||||
"npm": ">=11.5.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "app.vrcx",
|
||||
"productName": "VRCX",
|
||||
@@ -176,9 +196,5 @@
|
||||
"category": "public.app-category.utilities",
|
||||
"executableName": "VRCX"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"hazardous": "^0.3.0",
|
||||
"node-api-dotnet": "^0.9.18"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"lib": ["esnext", "dom"],
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*"],
|
||||
"exclude": ["../node_modules", "../build"]
|
||||
}
|
||||
+66
-14
@@ -49,6 +49,10 @@ let isOverlayActive = false;
|
||||
let appIsQuitting = false;
|
||||
const rootDir = app.getAppPath();
|
||||
|
||||
let tray = null;
|
||||
let trayIcon = null;
|
||||
let trayIconNotify = null;
|
||||
|
||||
// Get launch arguments
|
||||
let appImagePath = process.env.APPIMAGE;
|
||||
const args = process.argv.slice(1);
|
||||
@@ -70,12 +74,19 @@ if (process.defaultApp && process.platform !== 'win32') {
|
||||
}
|
||||
}
|
||||
|
||||
const version = getVersion();
|
||||
const homePath = getHomePath();
|
||||
tryRelaunchWithArgs(args);
|
||||
tryCopyFromWinePrefix();
|
||||
const userDataPath = getElectronUserDataPath();
|
||||
console.log('Electron userData path:', userDataPath);
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
app.setPath('userData', userDataPath);
|
||||
|
||||
const armPath = path.join(rootDir, 'build/Electron/VRCX-Electron-arm64.cjs');
|
||||
if (fs.existsSync(armPath)) {
|
||||
if (process.arch === 'arm64' && fs.existsSync(armPath)) {
|
||||
require(armPath);
|
||||
} else {
|
||||
require(path.join(rootDir, 'build/Electron/VRCX-Electron.cjs'));
|
||||
@@ -94,15 +105,13 @@ const OVERLAY_SHARED_WIDTH = Math.max(
|
||||
OVERLAY_WRIST_FRAME_WIDTH,
|
||||
OVERLAY_HMD_FRAME_WIDTH
|
||||
);
|
||||
const OVERLAY_FRAME_SIZE =
|
||||
OVERLAY_SHARED_WIDTH * OVERLAY_SHARED_HEIGHT * 4;
|
||||
const OVERLAY_FRAME_SIZE = OVERLAY_SHARED_WIDTH * OVERLAY_SHARED_HEIGHT * 4;
|
||||
const OVERLAY_SHM_PATH = '/dev/shm/vrcx_overlay';
|
||||
|
||||
function createOverlayWindowShm() {
|
||||
fs.writeFileSync(OVERLAY_SHM_PATH, Buffer.alloc(OVERLAY_FRAME_SIZE + 1));
|
||||
}
|
||||
|
||||
const version = getVersion();
|
||||
interopApi.getDotNetObject('ProgramElectron').PreInit(version, args);
|
||||
interopApi.getDotNetObject('VRCXStorage').Load();
|
||||
interopApi.getDotNetObject('ProgramElectron').Init();
|
||||
@@ -211,6 +220,7 @@ ipcMain.handle('app:restart', () => {
|
||||
}
|
||||
}
|
||||
app.relaunch(options);
|
||||
destroyTray();
|
||||
app.exit(0);
|
||||
} else {
|
||||
app.relaunch();
|
||||
@@ -287,6 +297,7 @@ function tryRelaunchWithArgs(args) {
|
||||
|
||||
child.unref();
|
||||
|
||||
destroyTray();
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
@@ -297,6 +308,7 @@ function createWindow() {
|
||||
const y = parseInt(VRCXStorage.Get('VRCX_LocationY')) || 0;
|
||||
const width = parseInt(VRCXStorage.Get('VRCX_SizeWidth')) || 1920;
|
||||
const height = parseInt(VRCXStorage.Get('VRCX_SizeHeight')) || 1080;
|
||||
const zoomLevel = parseFloat(VRCXStorage.Get('VRCX_ZoomLevel')) || 0;
|
||||
mainWindow = new BrowserWindow({
|
||||
x,
|
||||
y,
|
||||
@@ -334,21 +346,31 @@ function createWindow() {
|
||||
// Open the DevTools.
|
||||
// mainWindow.webContents.openDevTools()
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow.webContents.setZoomLevel(zoomLevel);
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.control && input.key === '=') {
|
||||
mainWindow.webContents.setZoomLevel(
|
||||
mainWindow.webContents.getZoomLevel() + 1
|
||||
);
|
||||
}
|
||||
if (input.control && input.key === '-') {
|
||||
mainWindow.webContents.setZoomLevel(
|
||||
mainWindow.webContents.getZoomLevel() - 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => {
|
||||
const currentZoom = mainWindow.webContents.getZoomLevel();
|
||||
let currentZoom = mainWindow.webContents.getZoomLevel();
|
||||
if (zoomDirection === 'in') {
|
||||
mainWindow.webContents.setZoomLevel(currentZoom + 1);
|
||||
mainWindow.webContents.setZoomLevel(++currentZoom);
|
||||
} else {
|
||||
mainWindow.webContents.setZoomLevel(currentZoom - 1);
|
||||
mainWindow.webContents.setZoomLevel(--currentZoom);
|
||||
}
|
||||
VRCXStorage.Set('VRCX_ZoomLevel', currentZoom.toString());
|
||||
});
|
||||
mainWindow.webContents.setVisualZoomLevelLimits(1, 5);
|
||||
|
||||
@@ -459,9 +481,13 @@ function writeOverlayFrame(imageBuffer) {
|
||||
}
|
||||
}
|
||||
|
||||
let tray = null;
|
||||
let trayIcon = null;
|
||||
let trayIconNotify = null;
|
||||
|
||||
function destroyTray() {
|
||||
if (tray) {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
}
|
||||
}
|
||||
function createTray() {
|
||||
if (process.platform === 'darwin') {
|
||||
const image = nativeImage.createFromPath(
|
||||
@@ -521,7 +547,9 @@ function createTray() {
|
||||
}
|
||||
|
||||
function setTrayIconNotification(notify) {
|
||||
tray.setImage(notify ? trayIconNotify : trayIcon);
|
||||
if (tray) {
|
||||
tray.setImage(notify ? trayIconNotify : trayIcon);
|
||||
}
|
||||
}
|
||||
|
||||
async function installVRCX() {
|
||||
@@ -713,15 +741,38 @@ function downloadIcon(url, targetPath) {
|
||||
});
|
||||
}
|
||||
|
||||
function getElectronUserDataPath() {
|
||||
const electronUserData = 'ElectronUserData';
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(getVRCXPath(), electronUserData);
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(
|
||||
process.env.HOME,
|
||||
'Library/Caches/VRCX',
|
||||
electronUserData
|
||||
);
|
||||
}
|
||||
// Linux or other
|
||||
let cacheHome = process.env.XDG_CACHE_HOME;
|
||||
if (!cacheHome) {
|
||||
cacheHome = path.join(process.env.HOME, '.cache');
|
||||
}
|
||||
return path.join(cacheHome, 'VRCX', electronUserData);
|
||||
}
|
||||
|
||||
function getVRCXPath() {
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(process.env.APPDATA, 'VRCX');
|
||||
} else if (process.platform === 'linux') {
|
||||
return path.join(process.env.HOME, '.config/VRCX');
|
||||
} else if (process.platform === 'darwin') {
|
||||
return path.join(process.env.HOME, 'Library/Application Support/VRCX');
|
||||
}
|
||||
return '';
|
||||
// Linux or other
|
||||
let configHome = process.env.XDG_CONFIG_HOME;
|
||||
if (!configHome) {
|
||||
configHome = path.join(process.env.HOME, '.config');
|
||||
}
|
||||
return path.join(configHome, 'VRCX');
|
||||
}
|
||||
|
||||
function getHomePath() {
|
||||
@@ -876,6 +927,7 @@ app.on('before-quit', function () {
|
||||
// Mark it as a quitting state to make macOS Dock's "Quit" action take effect.
|
||||
appIsQuitting = true;
|
||||
disposeOverlay();
|
||||
destroyTray();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', function () {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onUpdateImage: (callback) => ipcRenderer.on('update-image', (event, base64) => callback(base64))
|
||||
});
|
||||
onUpdateImage: (callback) =>
|
||||
ipcRenderer.on('update-image', (event, base64) => callback(base64))
|
||||
});
|
||||
|
||||
+20
-17
@@ -4,16 +4,15 @@
|
||||
|
||||
<div
|
||||
id="x-app"
|
||||
class="x-app"
|
||||
:class="{ 'with-macos-titlebar': isMacOS }"
|
||||
ondragenter="event.preventDefault()"
|
||||
ondragover="event.preventDefault()"
|
||||
ondrop="event.preventDefault()">
|
||||
class="flex w-screen h-screen overflow-hidden cursor-default [&>.x-container]:pt-[15px]"
|
||||
:class="{ 'pt-7': isMacOS }">
|
||||
<RouterView></RouterView>
|
||||
<Toaster position="top-center"></Toaster>
|
||||
<Toaster position="top-center" :theme="theme"></Toaster>
|
||||
|
||||
<AlertDialogModal></AlertDialogModal>
|
||||
<PromptDialogModal></PromptDialogModal>
|
||||
<OtpDialogModal></OtpDialogModal>
|
||||
<DatabaseUpgradeDialog></DatabaseUpgradeDialog>
|
||||
|
||||
<VRCXUpdateDialog></VRCXUpdateDialog>
|
||||
</div>
|
||||
@@ -24,29 +23,40 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeMount, onMounted } from 'vue';
|
||||
|
||||
import { addGameLogEvent, getGameLogTable } from './coordinators/gameLogCoordinator';
|
||||
import { runCheckVRChatDebugLoggingFlow, runUpdateIsGameRunningFlow, runUpdateIsHmdAfkFlow } from './coordinators/gameCoordinator';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { createGlobalStores } from './stores';
|
||||
import { initNoty } from './plugin/noty';
|
||||
import { initNoty } from './plugins/noty';
|
||||
|
||||
import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue';
|
||||
import DatabaseUpgradeDialog from './components/dialogs/DatabaseUpgradeDialog.vue';
|
||||
import MacOSTitleBar from './components/MacOSTitleBar.vue';
|
||||
import OtpDialogModal from './components/ui/dialog/OtpDialogModal.vue';
|
||||
import PromptDialogModal from './components/ui/dialog/PromptDialogModal.vue';
|
||||
import VRCXUpdateDialog from './components/dialogs/VRCXUpdateDialog.vue';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
import '@/app.css';
|
||||
|
||||
console.log(`isLinux: ${LINUX}`);
|
||||
|
||||
const isMacOS = computed(() => navigator.platform.includes('Mac'));
|
||||
|
||||
const theme = computed(() => {
|
||||
return store.appearanceSettings.isDarkMode ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
initNoty();
|
||||
|
||||
const store = createGlobalStores();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.$pinia = store;
|
||||
// Bridge: attach coordinator functions to store for C# IPC callbacks
|
||||
store.game.updateIsGameRunning = runUpdateIsGameRunningFlow;
|
||||
store.game.updateIsHmdAfk = runUpdateIsHmdAfkFlow;
|
||||
store.gameLog.addGameLogEvent = addGameLogEvent;
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
@@ -54,17 +64,10 @@
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
store.gameLog.getGameLogTable();
|
||||
getGameLogTable();
|
||||
await store.auth.migrateStoredUsers();
|
||||
store.auth.autoLoginAfterMounted();
|
||||
store.vrcx.checkAutoBackupRestoreVrcRegistry();
|
||||
store.game.checkVRChatDebugLogging();
|
||||
runCheckVRChatDebugLoggingFlow();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add title bar spacing for macOS */
|
||||
.x-app.with-macos-titlebar {
|
||||
padding-top: 28px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mockRequest = vi.fn();
|
||||
const mockPatchAndRefetchActiveQuery = vi.fn(() => Promise.resolve());
|
||||
|
||||
const mockApplyCurrentUser = vi.fn((json) => ({
|
||||
id: json.id || 'usr_me',
|
||||
...json
|
||||
}));
|
||||
const mockApplyUser = vi.fn((json) => ({ ...json }));
|
||||
const mockApplyWorld = vi.fn((json) => ({ ...json }));
|
||||
|
||||
vi.mock('../../services/request', () => ({
|
||||
request: (...args) => mockRequest(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../stores', () => ({
|
||||
useUserStore: () => ({
|
||||
currentUser: { id: 'usr_me' },
|
||||
applyCurrentUser: mockApplyCurrentUser,
|
||||
applyUser: mockApplyUser
|
||||
}),
|
||||
useWorldStore: () => ({
|
||||
applyWorld: mockApplyWorld
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../coordinators/userCoordinator', () => ({
|
||||
applyCurrentUser: (...args) => mockApplyCurrentUser(...args),
|
||||
applyUser: (...args) => mockApplyUser(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../coordinators/worldCoordinator', () => ({
|
||||
applyWorld: (...args) => mockApplyWorld(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../queries', () => ({
|
||||
patchAndRefetchActiveQuery: (...args) =>
|
||||
mockPatchAndRefetchActiveQuery(...args),
|
||||
queryKeys: {
|
||||
user: (userId) => ['user', userId],
|
||||
avatar: (avatarId) => ['avatar', avatarId],
|
||||
world: (worldId) => ['world', worldId]
|
||||
},
|
||||
entityQueryPolicies: {
|
||||
user: {},
|
||||
avatar: {},
|
||||
world: {},
|
||||
worldCollection: {}
|
||||
}
|
||||
}));
|
||||
|
||||
import avatarRequest from '../avatar';
|
||||
import userRequest from '../user';
|
||||
import worldRequest from '../world';
|
||||
|
||||
describe('entity mutation query sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('saveCurrentUser patches and refetches active user query', async () => {
|
||||
mockRequest.mockResolvedValue({ id: 'usr_me', status: 'active' });
|
||||
|
||||
await userRequest.saveCurrentUser({ status: 'active' });
|
||||
|
||||
expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['user', 'usr_me']
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('saveAvatar patches and refetches active avatar query', async () => {
|
||||
mockRequest.mockResolvedValue({ id: 'avtr_1', name: 'Avatar' });
|
||||
|
||||
await avatarRequest.saveAvatar({ id: 'avtr_1', name: 'Avatar' });
|
||||
|
||||
expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['avatar', 'avtr_1']
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('saveWorld patches and refetches active world query', async () => {
|
||||
mockRequest.mockResolvedValue({ id: 'wrld_1', name: 'World' });
|
||||
|
||||
await worldRequest.saveWorld({ id: 'wrld_1', name: 'World' });
|
||||
|
||||
expect(mockPatchAndRefetchActiveQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['world', 'wrld_1']
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mockRequest = vi.fn();
|
||||
const mockInvalidateQueries = vi.fn().mockResolvedValue();
|
||||
const mockHandleFavoriteAdd = vi.fn();
|
||||
const mockHandleFavoriteDelete = vi.fn();
|
||||
const mockHandleFavoriteGroupClear = vi.fn();
|
||||
|
||||
vi.mock('../../services/request', () => ({
|
||||
request: (...args) => mockRequest(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../stores', () => ({
|
||||
useUserStore: () => ({
|
||||
currentUser: { id: 'usr_me' }
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../coordinators/favoriteCoordinator', () => ({
|
||||
handleFavoriteAdd: (...args) => mockHandleFavoriteAdd(...args),
|
||||
handleFavoriteDelete: (...args) => mockHandleFavoriteDelete(...args),
|
||||
handleFavoriteGroupClear: (...args) => mockHandleFavoriteGroupClear(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../queries', () => ({
|
||||
queryClient: {
|
||||
invalidateQueries: (...args) => mockInvalidateQueries(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
import favoriteRequest from '../favorite';
|
||||
|
||||
describe('favorite query sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('favorite mutations invalidate active favorite queries', async () => {
|
||||
mockRequest.mockResolvedValue({ ok: true });
|
||||
|
||||
await favoriteRequest.addFavorite({
|
||||
type: 'world',
|
||||
favoriteId: 'wrld_1'
|
||||
});
|
||||
await favoriteRequest.deleteFavorite({ objectId: 'fav_1' });
|
||||
await favoriteRequest.saveFavoriteGroup({
|
||||
type: 'world',
|
||||
group: 'worlds1',
|
||||
displayName: 'Worlds'
|
||||
});
|
||||
await favoriteRequest.clearFavoriteGroup({
|
||||
type: 'world',
|
||||
group: 'worlds1'
|
||||
});
|
||||
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledTimes(4);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['favorite'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mockRequest = vi.fn();
|
||||
const mockInvalidateQueries = vi.fn().mockResolvedValue();
|
||||
const mockApplyUser = vi.fn((json) => json);
|
||||
|
||||
vi.mock('../../services/request', () => ({
|
||||
request: (...args) => mockRequest(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/user', () => ({
|
||||
useUserStore: () => ({
|
||||
applyUser: (...args) => mockApplyUser(...args)
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../coordinators/userCoordinator', () => ({
|
||||
applyUser: (...args) => mockApplyUser(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../queries', () => ({
|
||||
queryClient: {
|
||||
invalidateQueries: (...args) => mockInvalidateQueries(...args)
|
||||
},
|
||||
entityQueryPolicies: {
|
||||
user: {},
|
||||
avatar: {},
|
||||
world: {},
|
||||
worldCollection: {}
|
||||
}
|
||||
}));
|
||||
|
||||
import friendRequest from '../friend';
|
||||
|
||||
describe('friend query sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('friend mutations invalidate active friends queries', async () => {
|
||||
mockRequest.mockResolvedValue({ ok: true });
|
||||
|
||||
await friendRequest.sendFriendRequest({ userId: 'usr_1' });
|
||||
await friendRequest.cancelFriendRequest({ userId: 'usr_1' });
|
||||
await friendRequest.deleteFriend({ userId: 'usr_1' });
|
||||
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledTimes(3);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['friends'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mockRequest = vi.fn();
|
||||
const mockInvalidateQueries = vi.fn().mockResolvedValue();
|
||||
const mockApplyGroup = vi.fn((json) => json);
|
||||
|
||||
vi.mock('../../services/request', () => ({
|
||||
request: (...args) => mockRequest(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../stores', () => ({
|
||||
useGroupStore: () => ({
|
||||
applyGroup: (...args) => mockApplyGroup(...args)
|
||||
}),
|
||||
useUserStore: () => ({
|
||||
currentUser: { id: 'usr_me' }
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../queries', () => ({
|
||||
queryClient: {
|
||||
invalidateQueries: (...args) => mockInvalidateQueries(...args)
|
||||
},
|
||||
entityQueryPolicies: {
|
||||
user: {},
|
||||
avatar: {},
|
||||
world: {},
|
||||
worldCollection: {}
|
||||
}
|
||||
}));
|
||||
|
||||
import groupRequest from '../group';
|
||||
|
||||
describe('group query sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('group mutations invalidate scoped active group queries', async () => {
|
||||
mockRequest.mockResolvedValue({ ok: true });
|
||||
|
||||
await groupRequest.setGroupRepresentation('grp_1', {
|
||||
isRepresenting: true
|
||||
});
|
||||
await groupRequest.deleteGroupPost({
|
||||
groupId: 'grp_1',
|
||||
postId: 'post_1'
|
||||
});
|
||||
await groupRequest.setGroupMemberProps('usr_me', 'grp_1', {
|
||||
visibility: 'visible'
|
||||
});
|
||||
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledTimes(3);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['group', 'grp_1'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mockRequest = vi.fn();
|
||||
const mockInvalidateQueries = vi.fn().mockResolvedValue();
|
||||
const mockRemoveQueries = vi.fn();
|
||||
|
||||
vi.mock('../../services/request', () => ({
|
||||
request: (...args) => mockRequest(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../stores', () => ({
|
||||
useUserStore: () => ({
|
||||
currentUser: { id: 'usr_me' }
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../queries', () => ({
|
||||
queryClient: {
|
||||
invalidateQueries: (...args) => mockInvalidateQueries(...args),
|
||||
removeQueries: (...args) => mockRemoveQueries(...args)
|
||||
},
|
||||
queryKeys: {
|
||||
galleryFiles: (params) => ['gallery', 'files', params],
|
||||
prints: (params) => ['gallery', 'prints', params],
|
||||
print: (printId) => ['gallery', 'print', printId],
|
||||
inventoryItems: (params) => ['inventory', 'items', params],
|
||||
userInventoryItem: (params) => [
|
||||
'inventory',
|
||||
'item',
|
||||
params.userId,
|
||||
params.inventoryId
|
||||
],
|
||||
file: (fileId) => ['file', fileId]
|
||||
}
|
||||
}));
|
||||
|
||||
import miscRequest from '../misc';
|
||||
import vrcPlusIconRequest from '../vrcPlusIcon';
|
||||
import vrcPlusImageRequest from '../vrcPlusImage';
|
||||
|
||||
describe('media and inventory query sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('media mutations invalidate gallery queries and file delete removes file query', async () => {
|
||||
mockRequest.mockResolvedValue({ ok: true });
|
||||
|
||||
await vrcPlusIconRequest.deleteFile('file_icon_1');
|
||||
await vrcPlusImageRequest.deletePrint('print_1');
|
||||
await vrcPlusImageRequest.uploadEmoji('img', { tag: 'emoji' });
|
||||
await miscRequest.deleteFile('file_misc_1');
|
||||
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['gallery'],
|
||||
refetchType: 'active'
|
||||
});
|
||||
expect(mockRemoveQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['file', 'file_misc_1'],
|
||||
exact: true
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const mockFetchWithEntityPolicy = vi.fn();
|
||||
const mockGetUser = vi.fn();
|
||||
const mockGetWorlds = vi.fn();
|
||||
const mockGetGroupCalendar = vi.fn();
|
||||
|
||||
vi.mock('../../queries', () => ({
|
||||
queryClient: {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
entityQueryPolicies: {
|
||||
user: {
|
||||
staleTime: 20000,
|
||||
gcTime: 90000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
worldCollection: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
groupCollection: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
groupCalendarCollection: {
|
||||
staleTime: 120000,
|
||||
gcTime: 600000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
groupFollowingCalendarCollection: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
groupFeaturedCalendarCollection: {
|
||||
staleTime: 300000,
|
||||
gcTime: 900000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
groupCalendarEvent: {
|
||||
staleTime: 120000,
|
||||
gcTime: 600000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
avatar: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
avatarCollection: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
avatarGallery: {
|
||||
staleTime: 30000,
|
||||
gcTime: 120000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
world: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
group: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
friendList: {
|
||||
staleTime: 20000,
|
||||
gcTime: 90000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
favoriteCollection: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
galleryCollection: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
inventoryCollection: {
|
||||
staleTime: 20000,
|
||||
gcTime: 120000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
inventoryObject: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
fileAnalysis: {
|
||||
staleTime: 120000,
|
||||
gcTime: 600000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
worldPersistData: {
|
||||
staleTime: 120000,
|
||||
gcTime: 600000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
mutualCounts: {
|
||||
staleTime: 120000,
|
||||
gcTime: 600000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
visits: {
|
||||
staleTime: 300000,
|
||||
gcTime: 900000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
},
|
||||
fileObject: {
|
||||
staleTime: 60000,
|
||||
gcTime: 300000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
},
|
||||
fetchWithEntityPolicy: (...args) => mockFetchWithEntityPolicy(...args),
|
||||
queryKeys: {
|
||||
user: (userId) => ['user', userId],
|
||||
avatars: (params) => ['avatar', 'list', params],
|
||||
worldsByUser: (params) => ['worlds', 'user', params.userId, params],
|
||||
groupCalendar: (groupId) => ['group', groupId, 'calendar'],
|
||||
groupCalendars: (params) => ['group', 'calendar', params],
|
||||
followingGroupCalendars: (params) => [
|
||||
'group',
|
||||
'calendar',
|
||||
'following',
|
||||
params
|
||||
],
|
||||
featuredGroupCalendars: (params) => [
|
||||
'group',
|
||||
'calendar',
|
||||
'featured',
|
||||
params
|
||||
],
|
||||
avatar: (avatarId) => ['avatar', avatarId],
|
||||
world: (worldId) => ['world', worldId],
|
||||
group: (groupId, includeRoles) => [
|
||||
'group',
|
||||
groupId,
|
||||
Boolean(includeRoles)
|
||||
],
|
||||
groupPosts: (params) => ['group', params.groupId, 'posts', params],
|
||||
groupMember: (params) => [
|
||||
'group',
|
||||
params.groupId,
|
||||
'member',
|
||||
params.userId
|
||||
],
|
||||
groupMembers: (params) => ['group', params.groupId, 'members', params],
|
||||
groupGallery: (params) => [
|
||||
'group',
|
||||
params.groupId,
|
||||
'gallery',
|
||||
params.galleryId,
|
||||
params
|
||||
],
|
||||
groupCalendarEvent: (params) => [
|
||||
'group',
|
||||
params.groupId,
|
||||
'calendarEvent',
|
||||
params.eventId
|
||||
],
|
||||
avatarGallery: (avatarId) => ['avatar', avatarId, 'gallery'],
|
||||
friends: (params) => ['friends', params],
|
||||
favoriteLimits: () => ['favorite', 'limits'],
|
||||
favorites: (params) => ['favorite', 'items', params],
|
||||
favoriteGroups: (params) => ['favorite', 'groups', params],
|
||||
favoriteWorlds: (params) => ['favorite', 'worlds', params],
|
||||
favoriteAvatars: (params) => ['favorite', 'avatars', params],
|
||||
galleryFiles: (params) => ['gallery', 'files', params],
|
||||
prints: (params) => ['gallery', 'prints', params],
|
||||
print: (printId) => ['gallery', 'print', printId],
|
||||
inventoryItem: (inventoryId) => ['inventory', 'item', inventoryId],
|
||||
userInventoryItem: (params) => [
|
||||
'inventory',
|
||||
'item',
|
||||
params.userId,
|
||||
params.inventoryId
|
||||
],
|
||||
inventoryItems: (params) => ['inventory', 'items', params],
|
||||
inventoryTemplate: (inventoryTemplateId) => [
|
||||
'inventory',
|
||||
'template',
|
||||
inventoryTemplateId
|
||||
],
|
||||
fileAnalysis: (params) => [
|
||||
'analysis',
|
||||
params.fileId,
|
||||
Number(params.version),
|
||||
String(params.variant || '')
|
||||
],
|
||||
worldPersistData: (worldId) => ['world', worldId, 'persistData'],
|
||||
mutualCounts: (userId) => ['user', userId, 'mutualCounts'],
|
||||
visits: () => ['visits'],
|
||||
file: (fileId) => ['file', fileId]
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../user', () => ({
|
||||
default: {
|
||||
getUser: (...args) => mockGetUser(...args),
|
||||
getMutualCounts: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../world', () => ({
|
||||
default: {
|
||||
getWorlds: (...args) => mockGetWorlds(...args),
|
||||
getWorld: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../group', () => ({
|
||||
default: {
|
||||
getGroupCalendar: (...args) => mockGetGroupCalendar(...args),
|
||||
getGroup: vi.fn(),
|
||||
getGroupPosts: vi.fn(),
|
||||
getGroupMember: vi.fn(),
|
||||
getGroupMembers: vi.fn(),
|
||||
getGroupGallery: vi.fn(),
|
||||
getGroupCalendarEvent: vi.fn(),
|
||||
getGroupCalendars: vi.fn(),
|
||||
getFollowingGroupCalendars: vi.fn(),
|
||||
getFeaturedGroupCalendars: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../avatar', () => ({
|
||||
default: {
|
||||
getAvatar: vi.fn(),
|
||||
getAvatarGallery: vi.fn(),
|
||||
getAvatars: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../friend', () => ({ default: { getFriends: vi.fn() } }));
|
||||
vi.mock('../favorite', () => ({
|
||||
default: {
|
||||
getFavoriteLimits: vi.fn(),
|
||||
getFavorites: vi.fn(),
|
||||
getFavoriteGroups: vi.fn(),
|
||||
getFavoriteWorlds: vi.fn(),
|
||||
getFavoriteAvatars: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../vrcPlusIcon', () => ({ default: { getFileList: vi.fn() } }));
|
||||
vi.mock('../vrcPlusImage', () => ({
|
||||
default: { getPrints: vi.fn(), getPrint: vi.fn() }
|
||||
}));
|
||||
vi.mock('../inventory', () => ({
|
||||
default: {
|
||||
getUserInventoryItem: vi.fn(),
|
||||
getInventoryItem: vi.fn(),
|
||||
getInventoryItems: vi.fn(),
|
||||
getInventoryTemplate: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../misc', () => ({
|
||||
default: {
|
||||
getFile: vi.fn(),
|
||||
getFileAnalysis: vi.fn(),
|
||||
getVisits: vi.fn(),
|
||||
hasWorldPersistData: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import queryRequest from '../queryRequest';
|
||||
|
||||
describe('queryRequest', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('routes user fetch through policy wrapper and returns cache marker', async () => {
|
||||
const data = { json: { id: 'usr_1' }, params: { userId: 'usr_1' } };
|
||||
mockGetUser.mockResolvedValue(data);
|
||||
mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({
|
||||
data: await queryFn(),
|
||||
cache: true
|
||||
}));
|
||||
|
||||
const args = await queryRequest.fetch('user', { userId: 'usr_1' });
|
||||
|
||||
expect(mockFetchWithEntityPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['user', 'usr_1']
|
||||
})
|
||||
);
|
||||
expect(args.cache).toBe(true);
|
||||
expect(args.json.id).toBe('usr_1');
|
||||
});
|
||||
|
||||
test('uses same queryKey for user and user.dialog callers', async () => {
|
||||
const data = { json: { id: 'usr_1' }, params: { userId: 'usr_1' } };
|
||||
mockGetUser.mockResolvedValue(data);
|
||||
mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({
|
||||
data: await queryFn(),
|
||||
cache: false
|
||||
}));
|
||||
|
||||
await queryRequest.fetch('user', { userId: 'usr_1' });
|
||||
await queryRequest.fetch('user.dialog', { userId: 'usr_1' });
|
||||
|
||||
const baseCall = mockFetchWithEntityPolicy.mock.calls[0][0];
|
||||
const dialogCall = mockFetchWithEntityPolicy.mock.calls[1][0];
|
||||
expect(baseCall.queryKey).toEqual(['user', 'usr_1']);
|
||||
expect(dialogCall.queryKey).toEqual(baseCall.queryKey);
|
||||
});
|
||||
|
||||
test('applies staleTime zero for user.force', async () => {
|
||||
const data = { json: { id: 'usr_2' }, params: { userId: 'usr_2' } };
|
||||
mockGetUser.mockResolvedValue(data);
|
||||
mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({
|
||||
data: await queryFn(),
|
||||
cache: false
|
||||
}));
|
||||
|
||||
await queryRequest.fetch('user.force', { userId: 'usr_2' });
|
||||
|
||||
expect(mockFetchWithEntityPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
policy: expect.objectContaining({ staleTime: 0 }),
|
||||
label: 'user.force'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('applies staleTime 60000 for user.dialog', async () => {
|
||||
const data = { json: { id: 'usr_3' }, params: { userId: 'usr_3' } };
|
||||
mockGetUser.mockResolvedValue(data);
|
||||
mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({
|
||||
data: await queryFn(),
|
||||
cache: false
|
||||
}));
|
||||
|
||||
await queryRequest.fetch('user.dialog', { userId: 'usr_3' });
|
||||
|
||||
expect(mockFetchWithEntityPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
policy: expect.objectContaining({ staleTime: 60_000 }),
|
||||
label: 'user.dialog'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('supports worldsByUser option routing', async () => {
|
||||
const params = {
|
||||
userId: 'usr_me',
|
||||
n: 50,
|
||||
offset: 0,
|
||||
sort: 'updated',
|
||||
order: 'descending',
|
||||
user: 'me',
|
||||
releaseStatus: 'all',
|
||||
option: 'featured'
|
||||
};
|
||||
mockGetWorlds.mockResolvedValue({ json: [], params });
|
||||
mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({
|
||||
data: await queryFn(),
|
||||
cache: false
|
||||
}));
|
||||
|
||||
await queryRequest.fetch('worldsByUser', params);
|
||||
|
||||
expect(mockGetWorlds).toHaveBeenCalledWith(params, 'featured');
|
||||
expect(mockFetchWithEntityPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['worlds', 'user', 'usr_me', params]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('supports groupCalendar resource shape', async () => {
|
||||
mockGetGroupCalendar.mockResolvedValue({
|
||||
json: { results: [] },
|
||||
params: { groupId: 'grp_1' }
|
||||
});
|
||||
mockFetchWithEntityPolicy.mockImplementation(async ({ queryFn }) => ({
|
||||
data: await queryFn(),
|
||||
cache: false
|
||||
}));
|
||||
|
||||
await queryRequest.fetch('groupCalendar', { groupId: 'grp_1' });
|
||||
|
||||
expect(mockGetGroupCalendar).toHaveBeenCalledWith('grp_1');
|
||||
expect(mockFetchWithEntityPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['group', 'grp_1', 'calendar']
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on unknown resource', async () => {
|
||||
await expect(
|
||||
// @ts-expect-error verifying runtime guard
|
||||
queryRequest.fetch('missing_resource', {})
|
||||
).rejects.toThrow('Unknown query resource');
|
||||
});
|
||||
|
||||
test('throws on unknown caller variant', async () => {
|
||||
await expect(
|
||||
// @ts-expect-error verifying runtime guard
|
||||
queryRequest.fetch('user.unknown', { userId: 'usr_1' })
|
||||
).rejects.toThrow('Unknown query resource: user.unknown');
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { request } from '../service/request';
|
||||
import { useUserStore } from '../stores';
|
||||
import { request } from '../services/request';
|
||||
import { handleConfig } from '../coordinators/userCoordinator';
|
||||
|
||||
const loginReq = {
|
||||
/**
|
||||
@@ -63,7 +63,7 @@ const loginReq = {
|
||||
const args = {
|
||||
json
|
||||
};
|
||||
useUserStore().handleConfig(args);
|
||||
handleConfig(args);
|
||||
return args;
|
||||
});
|
||||
}
|
||||
|
||||
+44
-7
@@ -1,5 +1,7 @@
|
||||
import { request } from '../service/request';
|
||||
import { patchAndRefetchActiveQuery, queryKeys } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
import { useUserStore } from '../stores';
|
||||
import { applyCurrentUser } from '../coordinators/userCoordinator';
|
||||
|
||||
const avatarReq = {
|
||||
/**
|
||||
@@ -46,6 +48,15 @@ const avatarReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
patchAndRefetchActiveQuery({
|
||||
queryKey: queryKeys.avatar(params.id),
|
||||
nextData: args
|
||||
}).catch((err) => {
|
||||
console.error(
|
||||
'Failed to refresh avatar query after mutation:',
|
||||
err
|
||||
);
|
||||
});
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -55,7 +66,6 @@ const avatarReq = {
|
||||
* @returns {Promise<{json: any, params}>}
|
||||
*/
|
||||
selectAvatar(params) {
|
||||
const userStore = useUserStore();
|
||||
return request(`avatars/${params.avatarId}/select`, {
|
||||
method: 'PUT',
|
||||
params
|
||||
@@ -64,17 +74,29 @@ const avatarReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
userStore.applyCurrentUser(json);
|
||||
const ref = applyCurrentUser(json);
|
||||
patchAndRefetchActiveQuery({
|
||||
queryKey: queryKeys.user(ref.id),
|
||||
nextData: {
|
||||
json,
|
||||
params: { userId: ref.id },
|
||||
ref
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(
|
||||
'Failed to refresh current user query after avatar select:',
|
||||
err
|
||||
);
|
||||
});
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ avatarId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
selectFallbackAvatar(params) {
|
||||
const userStore = useUserStore();
|
||||
return request(`avatars/${params.avatarId}/selectfallback`, {
|
||||
method: 'PUT',
|
||||
params
|
||||
@@ -83,14 +105,27 @@ const avatarReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
userStore.applyCurrentUser(json);
|
||||
const ref = applyCurrentUser(json);
|
||||
patchAndRefetchActiveQuery({
|
||||
queryKey: queryKeys.user(ref.id),
|
||||
nextData: {
|
||||
json,
|
||||
params: { userId: ref.id },
|
||||
ref
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(
|
||||
'Failed to refresh current user query after fallback avatar select:',
|
||||
err
|
||||
);
|
||||
});
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ avatarId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
deleteAvatar(params) {
|
||||
return request(`avatars/${params.avatarId}`, {
|
||||
@@ -193,6 +228,8 @@ const avatarReq = {
|
||||
|
||||
/**
|
||||
* @param {{ imageData: string, avatarId: string }}
|
||||
* @param imageData
|
||||
* @param avatarId
|
||||
* @returns {Promise<{json: any, params}>}
|
||||
*/
|
||||
uploadAvatarGalleryImage(imageData, avatarId) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { request } from '../service/request';
|
||||
import { request } from '../services/request';
|
||||
|
||||
const avatarModerationReq = {
|
||||
getAvatarModerations() {
|
||||
@@ -14,7 +14,7 @@ const avatarModerationReq = {
|
||||
|
||||
/**
|
||||
* @param {{ avatarModerationType: string, targetAvatarId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
sendAvatarModeration(params) {
|
||||
return request('auth/user/avatarmoderations', {
|
||||
@@ -31,7 +31,7 @@ const avatarModerationReq = {
|
||||
|
||||
/**
|
||||
* @param {{ avatarModerationType: string, targetAvatarId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
deleteAvatarModeration(params) {
|
||||
return request(
|
||||
|
||||
+35
-8
@@ -1,10 +1,33 @@
|
||||
import { useFavoriteStore, useUserStore } from '../stores';
|
||||
import { request } from '../service/request';
|
||||
import {
|
||||
handleFavoriteAdd,
|
||||
handleFavoriteDelete,
|
||||
handleFavoriteGroupClear
|
||||
} from '../coordinators/favoriteCoordinator';
|
||||
import { queryClient } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function getCurrentUserId() {
|
||||
return useUserStore().currentUser.id;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function refetchActiveFavoriteQueries() {
|
||||
queryClient
|
||||
.invalidateQueries({
|
||||
queryKey: ['favorite'],
|
||||
refetchType: 'active'
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to refresh favorite queries:', err);
|
||||
});
|
||||
}
|
||||
|
||||
const favoriteReq = {
|
||||
getFavoriteLimits() {
|
||||
return request('auth/user/favoritelimits', {
|
||||
@@ -45,14 +68,15 @@ const favoriteReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
useFavoriteStore().handleFavoriteAdd(args);
|
||||
handleFavoriteAdd(args);
|
||||
refetchActiveFavoriteQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ objectId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
deleteFavorite(params) {
|
||||
return request(`favorites/${params.objectId}`, {
|
||||
@@ -62,14 +86,15 @@ const favoriteReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
useFavoriteStore().handleFavoriteDelete(params.objectId);
|
||||
handleFavoriteDelete(params.objectId);
|
||||
refetchActiveFavoriteQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ n: number, offset: number, type: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getFavoriteGroups(params) {
|
||||
return request('favorite/groups', {
|
||||
@@ -87,7 +112,7 @@ const favoriteReq = {
|
||||
/**
|
||||
*
|
||||
* @param {{ type: string, group: string, displayName?: string, visibility?: string }} params group is a name
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
saveFavoriteGroup(params) {
|
||||
return request(
|
||||
@@ -101,6 +126,7 @@ const favoriteReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveFavoriteQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -110,7 +136,7 @@ const favoriteReq = {
|
||||
* type: string,
|
||||
* group: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
clearFavoriteGroup(params) {
|
||||
return request(
|
||||
@@ -124,7 +150,8 @@ const favoriteReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
useFavoriteStore().handleFavoriteGroupClear(args);
|
||||
handleFavoriteGroupClear(args);
|
||||
refetchActiveFavoriteQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
+22
-3
@@ -1,5 +1,21 @@
|
||||
import { request } from '../service/request';
|
||||
import { queryClient } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { applyUser } from '../coordinators/userCoordinator';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function refetchActiveFriendListQueries() {
|
||||
queryClient
|
||||
.invalidateQueries({
|
||||
queryKey: ['friends'],
|
||||
refetchType: 'active'
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to refresh friend list queries:', err);
|
||||
});
|
||||
}
|
||||
|
||||
const friendReq = {
|
||||
/**
|
||||
@@ -7,7 +23,6 @@ const friendReq = {
|
||||
* @type {import('../types/api/friend').GetFriends}
|
||||
*/
|
||||
getFriends(params) {
|
||||
const userStore = useUserStore();
|
||||
return request('auth/user/friends', {
|
||||
method: 'GET',
|
||||
params
|
||||
@@ -21,7 +36,7 @@ const friendReq = {
|
||||
console.error('/friends gave us garbage', user);
|
||||
continue;
|
||||
}
|
||||
userStore.applyUser(user);
|
||||
applyUser(user);
|
||||
}
|
||||
return args;
|
||||
});
|
||||
@@ -39,6 +54,7 @@ const friendReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveFriendListQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -55,12 +71,14 @@ const friendReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveFriendListQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ userId: string }} params
|
||||
* @param customMsg
|
||||
* @returns {Promise<{json: any, params: { userId: string }}>}
|
||||
*/
|
||||
deleteFriend(params, customMsg) {
|
||||
@@ -72,6 +90,7 @@ const friendReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveFriendListQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
+76
-61
@@ -1,9 +1,32 @@
|
||||
import { useGroupStore, useUserStore } from '../stores';
|
||||
import { request } from '../service/request';
|
||||
import { useUserStore } from '../stores';
|
||||
import { applyGroup } from '../coordinators/groupCoordinator';
|
||||
import { queryClient } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function getCurrentUserId() {
|
||||
return useUserStore().currentUser.id;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param groupId
|
||||
*/
|
||||
function refetchActiveGroupScope(groupId) {
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
queryClient
|
||||
.invalidateQueries({
|
||||
queryKey: ['group', groupId],
|
||||
refetchType: 'active'
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to refresh scoped group queries:', err);
|
||||
});
|
||||
}
|
||||
const groupReq = {
|
||||
/**
|
||||
* @param {string} groupId
|
||||
@@ -20,13 +43,14 @@ const groupReq = {
|
||||
groupId,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
cancelGroupRequest(params) {
|
||||
return request(`groups/${params.groupId}/requests`, {
|
||||
@@ -42,7 +66,7 @@ const groupReq = {
|
||||
|
||||
/**
|
||||
* @param {{ groupId: string, postId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
deleteGroupPost(params) {
|
||||
return request(`groups/${params.groupId}/posts/${params.postId}`, {
|
||||
@@ -52,6 +76,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -69,39 +94,13 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
args.ref = applyGroup(json);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, ref: any, cache?: boolean, params}> }
|
||||
*/
|
||||
getCachedGroup(params) {
|
||||
const groupStore = useGroupStore();
|
||||
return new Promise((resolve, reject) => {
|
||||
const ref = groupStore.cachedGroups.get(params.groupId);
|
||||
if (typeof ref === 'undefined') {
|
||||
groupReq
|
||||
.getGroup(params)
|
||||
.then((args) => {
|
||||
args.ref = groupStore.applyGroup(args.json);
|
||||
resolve(args);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
resolve({
|
||||
cache: true,
|
||||
json: ref,
|
||||
params,
|
||||
ref
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {{ userId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getRepresentedGroup(params) {
|
||||
return request(`users/${params.userId}/groups/represented`, {
|
||||
@@ -116,7 +115,7 @@ const groupReq = {
|
||||
},
|
||||
/**
|
||||
* @param {{ userId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroups(params) {
|
||||
return request(`users/${params.userId}/groups`, {
|
||||
@@ -131,7 +130,7 @@ const groupReq = {
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
joinGroup(params) {
|
||||
return request(`groups/${params.groupId}/join`, {
|
||||
@@ -141,12 +140,13 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
leaveGroup(params) {
|
||||
return request(`groups/${params.groupId}/leave`, {
|
||||
@@ -156,12 +156,13 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {{ query: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
groupStrictsearch(params) {
|
||||
return request(`groups/strictsearch`, {
|
||||
@@ -183,7 +184,10 @@ const groupReq = {
|
||||
isSubscribedToAnnouncements: bool,
|
||||
managerNotes: string
|
||||
}
|
||||
*/
|
||||
* @param userId
|
||||
* @param groupId
|
||||
* @param params
|
||||
*/
|
||||
setGroupMemberProps(userId, groupId, params) {
|
||||
return request(`groups/${groupId}/members/${userId}`, {
|
||||
method: 'PUT',
|
||||
@@ -195,6 +199,7 @@ const groupReq = {
|
||||
groupId,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -204,7 +209,7 @@ const groupReq = {
|
||||
* groupId: string,
|
||||
* roleId: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
addGroupMemberRole(params) {
|
||||
return request(
|
||||
@@ -217,6 +222,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -226,7 +232,7 @@ const groupReq = {
|
||||
* groupId: string,
|
||||
* roleId: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
removeGroupMemberRole(params) {
|
||||
return request(
|
||||
@@ -239,6 +245,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -259,7 +266,7 @@ const groupReq = {
|
||||
n: number,
|
||||
offset: number
|
||||
}} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupPosts(params) {
|
||||
return request(`groups/${params.groupId}/posts`, {
|
||||
@@ -282,6 +289,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -294,6 +302,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -302,7 +311,7 @@ const groupReq = {
|
||||
* groupId: string,
|
||||
* userId: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params, ref?: any}> }
|
||||
* @returns { Promise<{json: any, params, ref?: any}> }
|
||||
*/
|
||||
getGroupMember(params) {
|
||||
return request(`groups/${params.groupId}/members/${params.userId}`, {
|
||||
@@ -321,7 +330,7 @@ const groupReq = {
|
||||
* n: number,
|
||||
* offset: number
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupMembers(params) {
|
||||
return request(`groups/${params.groupId}/members`, {
|
||||
@@ -342,7 +351,7 @@ const groupReq = {
|
||||
* n: number,
|
||||
* offset: number
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupMembersSearch(params) {
|
||||
return request(`groups/${params.groupId}/members/search`, {
|
||||
@@ -360,7 +369,7 @@ const groupReq = {
|
||||
* @param {{
|
||||
* groupId: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
blockGroup(params) {
|
||||
return request(`groups/${params.groupId}/block`, {
|
||||
@@ -370,6 +379,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -378,7 +388,7 @@ const groupReq = {
|
||||
* groupId: string,
|
||||
* userId: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
unblockGroup(params) {
|
||||
return request(`groups/${params.groupId}/members/${params.userId}`, {
|
||||
@@ -388,6 +398,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -396,7 +407,7 @@ const groupReq = {
|
||||
* groupId: string,
|
||||
* userId: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
sendGroupInvite(params) {
|
||||
return request(`groups/${params.groupId}/invites`, {
|
||||
@@ -417,7 +428,7 @@ const groupReq = {
|
||||
* groupId: string,
|
||||
* userId: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
kickGroupMember(params) {
|
||||
return request(`groups/${params.groupId}/members/${params.userId}`, {
|
||||
@@ -427,12 +438,13 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string, userId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
banGroupMember(params) {
|
||||
return request(`groups/${params.groupId}/bans`, {
|
||||
@@ -445,6 +457,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -456,12 +469,13 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string, userId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
deleteSentGroupInvite(params) {
|
||||
return request(`groups/${params.groupId}/invites/${params.userId}`, {
|
||||
@@ -496,6 +510,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -510,7 +525,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -526,6 +541,7 @@ const groupReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGroupScope(params.groupId);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -543,7 +559,7 @@ const groupReq = {
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupAuditLogTypes(params) {
|
||||
return request(`groups/${params.groupId}/auditLogTypes`, {
|
||||
@@ -557,8 +573,8 @@ const groupReq = {
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string, n: number, offset: number, eventTypes?: array }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @param {{groupId: string, n: number, offset: number, eventTypes?: Array}} params
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupLogs(params) {
|
||||
return request(`groups/${params.groupId}/auditLogs`, {
|
||||
@@ -574,7 +590,7 @@ const groupReq = {
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupInvites(params) {
|
||||
return request(`groups/${params.groupId}/invites`, {
|
||||
@@ -590,7 +606,7 @@ const groupReq = {
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupJoinRequests(params) {
|
||||
return request(`groups/${params.groupId}/requests`, {
|
||||
@@ -606,7 +622,7 @@ const groupReq = {
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupInstances(params) {
|
||||
return request(
|
||||
@@ -624,7 +640,7 @@ const groupReq = {
|
||||
},
|
||||
/**
|
||||
* @param {{ groupId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupRoles(params) {
|
||||
return request(`groups/${params.groupId}/roles`, {
|
||||
@@ -657,7 +673,7 @@ const groupReq = {
|
||||
order: string,
|
||||
sortBy: string
|
||||
}} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
groupSearch(params) {
|
||||
return request(`groups`, {
|
||||
@@ -678,7 +694,7 @@ const groupReq = {
|
||||
n: number,
|
||||
offset: number
|
||||
}} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupGallery(params) {
|
||||
return request(
|
||||
@@ -718,7 +734,7 @@ const groupReq = {
|
||||
groupId: string,
|
||||
eventId: string
|
||||
}} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getGroupCalendarEvent(params) {
|
||||
return request(`calendar/${params.groupId}/${params.eventId}`, {
|
||||
@@ -731,7 +747,6 @@ const groupReq = {
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @type {import('../types/api/group').GetCalendars}
|
||||
*/
|
||||
|
||||
+5
-4
@@ -1,5 +1,6 @@
|
||||
import { useAvatarStore, useWorldStore } from '../stores';
|
||||
import { request } from '../service/request';
|
||||
import { applyWorld } from '../coordinators/worldCoordinator';
|
||||
import { request } from '../services/request';
|
||||
|
||||
const imageReq = {
|
||||
async uploadAvatarFailCleanup(id) {
|
||||
@@ -21,7 +22,7 @@ const imageReq = {
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup avatar upload:', error);
|
||||
}
|
||||
avatarStore.avatarDialog.loading = false;
|
||||
avatarStore.setAvatarDialogLoading(false);
|
||||
},
|
||||
|
||||
async uploadAvatarImage(params, fileId) {
|
||||
@@ -154,7 +155,7 @@ const imageReq = {
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup world upload:', error);
|
||||
}
|
||||
worldStore.worldDialog.loading = false;
|
||||
worldStore.setWorldDialogLoading(false);
|
||||
},
|
||||
|
||||
async uploadWorldImage(params, fileId) {
|
||||
@@ -267,7 +268,7 @@ const imageReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
args.ref = worldStore.applyWorld(json);
|
||||
args.ref = applyWorld(json);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
+6
-3
@@ -3,7 +3,7 @@
|
||||
* Export all API requests from here
|
||||
*/
|
||||
|
||||
import { request } from '../service/request';
|
||||
import { request } from '../services/request';
|
||||
|
||||
import authRequest from './auth';
|
||||
import avatarModerationRequest from './avatarModeration';
|
||||
@@ -19,6 +19,7 @@ import miscRequest from './misc';
|
||||
import notificationRequest from './notification';
|
||||
import playerModerationRequest from './playerModeration';
|
||||
import propRequest from './prop';
|
||||
import queryRequest from './queryRequest';
|
||||
import userRequest from './user';
|
||||
import vrcPlusIconRequest from './vrcPlusIcon';
|
||||
import vrcPlusImageRequest from './vrcPlusImage';
|
||||
@@ -43,7 +44,8 @@ window.request = {
|
||||
groupRequest,
|
||||
inventoryRequest,
|
||||
propRequest,
|
||||
imageRequest
|
||||
imageRequest,
|
||||
queryRequest
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -65,5 +67,6 @@ export {
|
||||
groupRequest,
|
||||
inventoryRequest,
|
||||
propRequest,
|
||||
imageRequest
|
||||
imageRequest,
|
||||
queryRequest
|
||||
};
|
||||
|
||||
+2
-31
@@ -1,7 +1,7 @@
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import { i18n } from '../plugin/i18n';
|
||||
import { request } from '../service/request';
|
||||
import { i18n } from '../plugins/i18n';
|
||||
import { request } from '../services/request';
|
||||
import { useInstanceStore } from '../stores';
|
||||
|
||||
const instanceReq = {
|
||||
@@ -22,35 +22,6 @@ const instanceReq = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{worldId: string, instanceId: string}} params
|
||||
* @returns {Promise<{json: any, ref: any, cache?: boolean, params}>}
|
||||
*/
|
||||
getCachedInstance(params) {
|
||||
const instanceStore = useInstanceStore();
|
||||
return new Promise((resolve, reject) => {
|
||||
const ref = instanceStore.cachedInstances.get(
|
||||
`${params.worldId}:${params.instanceId}`
|
||||
);
|
||||
if (typeof ref === 'undefined') {
|
||||
instanceReq
|
||||
.getInstance(params)
|
||||
.then((args) => {
|
||||
args.ref = instanceStore.applyInstance(args.json);
|
||||
resolve(args);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
resolve({
|
||||
cache: true,
|
||||
json: ref,
|
||||
params,
|
||||
ref
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @type {import('../types/api/instance').CreateInstance}
|
||||
*/
|
||||
|
||||
+18
-1
@@ -1,4 +1,19 @@
|
||||
import { request } from '../service/request';
|
||||
import { queryClient } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function refetchActiveInventoryQueries() {
|
||||
queryClient
|
||||
.invalidateQueries({
|
||||
queryKey: ['inventory'],
|
||||
refetchType: 'active'
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to refresh inventory queries:', err);
|
||||
});
|
||||
}
|
||||
|
||||
const inventoryReq = {
|
||||
/**
|
||||
@@ -67,6 +82,7 @@ const inventoryReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveInventoryQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -102,6 +118,7 @@ const inventoryReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveInventoryQueries();
|
||||
return args;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { request } from '../service/request';
|
||||
import { request } from '../services/request';
|
||||
import { useUserStore } from '../stores';
|
||||
|
||||
function getCurrentUserId() {
|
||||
|
||||
+12
-3
@@ -1,6 +1,10 @@
|
||||
import { request } from '../service/request';
|
||||
import { queryClient, queryKeys } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
import { useUserStore } from '../stores';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function getCurrentUserId() {
|
||||
return useUserStore().currentUser.id;
|
||||
}
|
||||
@@ -38,7 +42,7 @@ const miscReq = {
|
||||
* reason: string,
|
||||
* type: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
reportUser(params) {
|
||||
return request(`feedback/${params.userId}/user`, {
|
||||
@@ -63,7 +67,7 @@ const miscReq = {
|
||||
* version: number,
|
||||
* variant: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
getFileAnalysis(params) {
|
||||
return request(
|
||||
@@ -192,11 +196,16 @@ const miscReq = {
|
||||
json,
|
||||
fileId
|
||||
};
|
||||
queryClient.removeQueries({
|
||||
queryKey: queryKeys.file(fileId),
|
||||
exact: true
|
||||
});
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param params
|
||||
* @params {{
|
||||
userId: string,
|
||||
emojiId: string
|
||||
|
||||
+56
-62
@@ -1,15 +1,9 @@
|
||||
import { useGalleryStore, useNotificationStore } from '../stores';
|
||||
import { request } from '../service/request';
|
||||
|
||||
/**
|
||||
* @returns {any}
|
||||
*/
|
||||
function getGalleryStore() {
|
||||
return useGalleryStore();
|
||||
}
|
||||
import { request } from '../services/request';
|
||||
import { useGalleryStore } from '../stores';
|
||||
|
||||
const notificationReq = {
|
||||
/** @typedef {{
|
||||
/**
|
||||
* @typedef {{
|
||||
* n: number,
|
||||
* offset: number,
|
||||
* sent: boolean,
|
||||
@@ -96,7 +90,7 @@ const notificationReq = {
|
||||
* rsvp?: boolean,
|
||||
* }} params
|
||||
* @param receiverUserId
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
sendInvite(params, receiverUserId) {
|
||||
return request(`invite/${receiverUserId}`, {
|
||||
@@ -115,7 +109,7 @@ const notificationReq = {
|
||||
return request(`invite/${receiverUserId}/photo`, {
|
||||
uploadImageLegacy: true,
|
||||
postData: JSON.stringify(params),
|
||||
imageData: getGalleryStore().uploadImage
|
||||
imageData: useGalleryStore().uploadImage
|
||||
}).then((json) => {
|
||||
const args = {
|
||||
json,
|
||||
@@ -144,7 +138,7 @@ const notificationReq = {
|
||||
return request(`requestInvite/${receiverUserId}/photo`, {
|
||||
uploadImageLegacy: true,
|
||||
postData: JSON.stringify(params),
|
||||
imageData: getGalleryStore().uploadImage
|
||||
imageData: useGalleryStore().uploadImage
|
||||
}).then((json) => {
|
||||
const args = {
|
||||
json,
|
||||
@@ -173,7 +167,7 @@ const notificationReq = {
|
||||
return request(`invite/${inviteId}/response/photo`, {
|
||||
uploadImageLegacy: true,
|
||||
postData: JSON.stringify(params),
|
||||
imageData: getGalleryStore().uploadImage,
|
||||
imageData: useGalleryStore().uploadImage,
|
||||
inviteId
|
||||
}).then((json) => {
|
||||
const args = {
|
||||
@@ -187,7 +181,7 @@ const notificationReq = {
|
||||
|
||||
/**
|
||||
* @param {{ notificationId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
acceptFriendRequestNotification(params) {
|
||||
return request(
|
||||
@@ -195,26 +189,18 @@ const notificationReq = {
|
||||
{
|
||||
method: 'PUT'
|
||||
}
|
||||
)
|
||||
.then((json) => {
|
||||
const args = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
useNotificationStore().handleNotificationAccept(args);
|
||||
return args;
|
||||
})
|
||||
.catch((err) => {
|
||||
// if friend request could not be found, delete it
|
||||
if (err && err.message && err.message.includes('404')) {
|
||||
useNotificationStore().handleNotificationHide({ params });
|
||||
}
|
||||
});
|
||||
).then((json) => {
|
||||
const args = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ notificationId: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
hideNotification(params) {
|
||||
return request(
|
||||
@@ -227,7 +213,38 @@ const notificationReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
useNotificationStore().handleNotificationHide(args);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ notificationId: string }} params
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
seeNotification(params) {
|
||||
return request(`auth/user/notifications/${params.notificationId}/see`, {
|
||||
method: 'PUT'
|
||||
}).then((json) => {
|
||||
const args = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ notificationId: string }} params
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
seeNotificationV2(params) {
|
||||
return request(`notifications/${params.notificationId}/see`, {
|
||||
method: 'POST'
|
||||
}).then((json) => {
|
||||
const args = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -238,12 +255,18 @@ const notificationReq = {
|
||||
* responseType: string,
|
||||
* responseData: string
|
||||
* }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
sendNotificationResponse(params) {
|
||||
return request(`notifications/${params.notificationId}/respond`, {
|
||||
method: 'POST',
|
||||
params
|
||||
}).then((json) => {
|
||||
const args = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -260,35 +283,6 @@ const notificationReq = {
|
||||
return args;
|
||||
});
|
||||
}
|
||||
|
||||
// sendInviteGalleryPhoto(params, receiverUserId) {
|
||||
// return request(`invite/${receiverUserId}/photo`, {
|
||||
// method: 'POST',
|
||||
// params
|
||||
// }).then((json) => {
|
||||
// const args = {
|
||||
// json,
|
||||
// params,
|
||||
// receiverUserId
|
||||
// };
|
||||
// API.$emit('NOTIFICATION:INVITE:GALLERYPHOTO:SEND', args);
|
||||
// return args;
|
||||
// });
|
||||
// },
|
||||
|
||||
// API.clearNotifications = function () {
|
||||
// return request('auth/user/notifications/clear', {
|
||||
// method: 'PUT'
|
||||
// }).then((json) => {
|
||||
// var args = {
|
||||
// json
|
||||
// };
|
||||
// // FIXME: NOTIFICATION:CLEAR 핸들링
|
||||
// this.$emit('NOTIFICATION:CLEAR', args);
|
||||
// return args;
|
||||
// });
|
||||
// };
|
||||
};
|
||||
// #endregion
|
||||
|
||||
export default notificationReq;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { request } from '../service/request';
|
||||
import { request } from '../services/request';
|
||||
|
||||
const playerModerationReq = {
|
||||
getPlayerModerations() {
|
||||
@@ -14,7 +14,7 @@ const playerModerationReq = {
|
||||
|
||||
/**
|
||||
* @param {{ moderated: string, type: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
// old-way: POST auth/user/blocks {blocked:userId}
|
||||
sendPlayerModeration(params) {
|
||||
@@ -32,7 +32,7 @@ const playerModerationReq = {
|
||||
|
||||
/**
|
||||
* @param {{ moderated: string, type: string }} params
|
||||
* @return { Promise<{json: any, params}> }
|
||||
* @returns { Promise<{json: any, params}> }
|
||||
*/
|
||||
// old-way: PUT auth/user/unblocks {blocked:userId}
|
||||
deletePlayerModeration(params) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { request } from '../service/request';
|
||||
import { request } from '../services/request';
|
||||
|
||||
const propReq = {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
entityQueryPolicies,
|
||||
fetchWithEntityPolicy,
|
||||
queryKeys
|
||||
} from '../queries';
|
||||
|
||||
import avatarRequest from './avatar';
|
||||
import favoriteRequest from './favorite';
|
||||
import friendRequest from './friend';
|
||||
import groupRequest from './group';
|
||||
import inventoryRequest from './inventory';
|
||||
import miscRequest from './misc';
|
||||
import userRequest from './user';
|
||||
import vrcPlusIconRequest from './vrcPlusIcon';
|
||||
import vrcPlusImageRequest from './vrcPlusImage';
|
||||
import worldRequest from './world';
|
||||
|
||||
const registry = Object.freeze({
|
||||
user: {
|
||||
key: (params) => queryKeys.user(params.userId),
|
||||
policy: entityQueryPolicies.user,
|
||||
queryFn: (params) => userRequest.getUser(params)
|
||||
},
|
||||
'user.dialog': {
|
||||
key: (params) => queryKeys.user(params.userId),
|
||||
policy: Object.freeze({
|
||||
...entityQueryPolicies.user,
|
||||
staleTime: 60_000
|
||||
}),
|
||||
queryFn: (params) => userRequest.getUser(params)
|
||||
},
|
||||
'user.force': {
|
||||
key: (params) => queryKeys.user(params.userId),
|
||||
policy: Object.freeze({
|
||||
...entityQueryPolicies.user,
|
||||
staleTime: 0
|
||||
}),
|
||||
queryFn: (params) => userRequest.getUser(params)
|
||||
},
|
||||
avatar: {
|
||||
key: (params) => queryKeys.avatar(params.avatarId),
|
||||
policy: entityQueryPolicies.avatar,
|
||||
queryFn: (params) => avatarRequest.getAvatar(params)
|
||||
},
|
||||
'avatar.dialog': {
|
||||
key: (params) => queryKeys.avatar(params.avatarId),
|
||||
policy: Object.freeze({
|
||||
...entityQueryPolicies.avatar,
|
||||
staleTime: 120_000
|
||||
}),
|
||||
queryFn: (params) => avatarRequest.getAvatar(params)
|
||||
},
|
||||
world: {
|
||||
key: (params) => queryKeys.world(params.worldId),
|
||||
policy: entityQueryPolicies.world,
|
||||
queryFn: (params) => worldRequest.getWorld(params)
|
||||
},
|
||||
'world.dialog': {
|
||||
key: (params) => queryKeys.world(params.worldId),
|
||||
policy: Object.freeze({
|
||||
...entityQueryPolicies.world,
|
||||
staleTime: 120_000
|
||||
}),
|
||||
queryFn: (params) => worldRequest.getWorld(params)
|
||||
},
|
||||
'world.location': {
|
||||
key: (params) => queryKeys.world(params.worldId),
|
||||
policy: Object.freeze({
|
||||
...entityQueryPolicies.world,
|
||||
staleTime: 120_000
|
||||
}),
|
||||
queryFn: (params) => worldRequest.getWorld(params)
|
||||
},
|
||||
'world.force': {
|
||||
key: (params) => queryKeys.world(params.worldId),
|
||||
policy: Object.freeze({
|
||||
...entityQueryPolicies.world,
|
||||
staleTime: 0
|
||||
}),
|
||||
queryFn: (params) => worldRequest.getWorld(params)
|
||||
},
|
||||
worldsByUser: {
|
||||
key: (params) => queryKeys.worldsByUser(params),
|
||||
policy: entityQueryPolicies.worldCollection,
|
||||
queryFn: (params) =>
|
||||
worldRequest.getWorlds(params, params.option || undefined)
|
||||
},
|
||||
group: {
|
||||
key: (params) => queryKeys.group(params.groupId, params.includeRoles),
|
||||
policy: entityQueryPolicies.group,
|
||||
queryFn: (params) => groupRequest.getGroup(params)
|
||||
},
|
||||
'group.dialog': {
|
||||
key: (params) => queryKeys.group(params.groupId, params.includeRoles),
|
||||
policy: Object.freeze({
|
||||
...entityQueryPolicies.group,
|
||||
staleTime: 120_000
|
||||
}),
|
||||
queryFn: (params) => groupRequest.getGroup(params)
|
||||
},
|
||||
'group.force': {
|
||||
key: (params) => queryKeys.group(params.groupId, params.includeRoles),
|
||||
policy: Object.freeze({
|
||||
...entityQueryPolicies.group,
|
||||
staleTime: 0
|
||||
}),
|
||||
queryFn: (params) => groupRequest.getGroup(params)
|
||||
},
|
||||
groupMember: {
|
||||
key: (params) => queryKeys.groupMember(params),
|
||||
policy: entityQueryPolicies.groupCollection,
|
||||
queryFn: (params) => groupRequest.getGroupMember(params)
|
||||
},
|
||||
groupMembers: {
|
||||
key: (params) => queryKeys.groupMembers(params),
|
||||
policy: entityQueryPolicies.groupCollection,
|
||||
queryFn: (params) => groupRequest.getGroupMembers(params)
|
||||
},
|
||||
groupGallery: {
|
||||
key: (params) => queryKeys.groupGallery(params),
|
||||
policy: entityQueryPolicies.groupCollection,
|
||||
queryFn: (params) => groupRequest.getGroupGallery(params)
|
||||
},
|
||||
groupCalendar: {
|
||||
key: (params) => queryKeys.groupCalendar(params.groupId),
|
||||
policy: entityQueryPolicies.groupCollection,
|
||||
queryFn: (params) => groupRequest.getGroupCalendar(params.groupId)
|
||||
},
|
||||
groupCalendarEvent: {
|
||||
key: (params) => queryKeys.groupCalendarEvent(params),
|
||||
policy: entityQueryPolicies.groupCalendarEvent,
|
||||
queryFn: (params) => groupRequest.getGroupCalendarEvent(params)
|
||||
},
|
||||
avatarGallery: {
|
||||
key: (params) => queryKeys.avatarGallery(params.avatarId),
|
||||
policy: entityQueryPolicies.avatarGallery,
|
||||
queryFn: (params) => avatarRequest.getAvatarGallery(params.avatarId)
|
||||
},
|
||||
favoriteLimits: {
|
||||
key: () => queryKeys.favoriteLimits(),
|
||||
policy: entityQueryPolicies.favoriteLimits,
|
||||
queryFn: () => favoriteRequest.getFavoriteLimits()
|
||||
},
|
||||
userInventoryItem: {
|
||||
key: (params) => queryKeys.userInventoryItem(params),
|
||||
policy: entityQueryPolicies.inventoryCollection,
|
||||
queryFn: (params) => inventoryRequest.getUserInventoryItem(params)
|
||||
},
|
||||
fileAnalysis: {
|
||||
key: (params) => queryKeys.fileAnalysis(params),
|
||||
policy: entityQueryPolicies.fileAnalysis,
|
||||
queryFn: (params) => miscRequest.getFileAnalysis(params)
|
||||
},
|
||||
worldPersistData: {
|
||||
key: (params) => queryKeys.worldPersistData(params.worldId),
|
||||
policy: entityQueryPolicies.worldPersistData,
|
||||
queryFn: (params) => miscRequest.hasWorldPersistData(params)
|
||||
},
|
||||
mutualCounts: {
|
||||
key: (params) => queryKeys.mutualCounts(params.userId),
|
||||
policy: entityQueryPolicies.mutualCounts,
|
||||
queryFn: (params) => userRequest.getMutualCounts(params)
|
||||
},
|
||||
visits: {
|
||||
key: () => queryKeys.visits(),
|
||||
policy: entityQueryPolicies.visits,
|
||||
queryFn: () => miscRequest.getVisits()
|
||||
},
|
||||
file: {
|
||||
key: (params) => queryKeys.file(params.fileId),
|
||||
policy: entityQueryPolicies.fileObject,
|
||||
queryFn: (params) => miscRequest.getFile(params)
|
||||
},
|
||||
avatarStyles: {
|
||||
key: () => queryKeys.avatarStyles(),
|
||||
policy: entityQueryPolicies.avatarStyles,
|
||||
queryFn: () => avatarRequest.getAvailableAvatarStyles()
|
||||
},
|
||||
representedGroup: {
|
||||
key: (params) => queryKeys.representedGroup(params.userId),
|
||||
policy: entityQueryPolicies.representedGroup,
|
||||
queryFn: (params) => groupRequest.getRepresentedGroup(params)
|
||||
},
|
||||
vrchatCredits: {
|
||||
key: () => queryKeys.vrchatCredits(),
|
||||
policy: entityQueryPolicies.vrchatCredits,
|
||||
queryFn: () => miscRequest.getVRChatCredits()
|
||||
}
|
||||
});
|
||||
|
||||
const queryRequest = {
|
||||
/**
|
||||
* @template T
|
||||
* @param {keyof typeof registry} resource
|
||||
* @param {any} [params]
|
||||
* @returns {Promise<T & {cache: boolean}>}
|
||||
*/
|
||||
async fetch(resource, params = {}) {
|
||||
const entry = registry[resource];
|
||||
if (!entry) {
|
||||
throw new Error(`Unknown query resource: ${String(resource)}`);
|
||||
}
|
||||
|
||||
const { data, cache } = await fetchWithEntityPolicy({
|
||||
queryKey: entry.key(params),
|
||||
policy: entry.policy,
|
||||
queryFn: () => entry.queryFn(params),
|
||||
label: resource
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
cache
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default queryRequest;
|
||||
+16
-36
@@ -1,5 +1,7 @@
|
||||
import { request } from '../service/request';
|
||||
import { patchAndRefetchActiveQuery, queryKeys } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
import { useUserStore } from '../stores';
|
||||
import { applyUser, applyCurrentUser } from '../coordinators/userCoordinator';
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
@@ -15,7 +17,6 @@ const userReq = {
|
||||
* @type {import('../types/api/user').GetUser}
|
||||
*/
|
||||
getUser(params) {
|
||||
const userStore = useUserStore();
|
||||
return request(`users/${params.userId}`, {
|
||||
method: 'GET'
|
||||
}).then((json) => {
|
||||
@@ -28,39 +29,12 @@ const userReq = {
|
||||
const args = {
|
||||
json,
|
||||
params,
|
||||
ref: userStore.applyUser(json)
|
||||
ref: applyUser(json)
|
||||
};
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch user from cache if they're in it. Otherwise, calls API.
|
||||
* @type {import('../types/api/user').GetCachedUser}
|
||||
*/
|
||||
getCachedUser(params) {
|
||||
const userStore = useUserStore();
|
||||
return new Promise((resolve, reject) => {
|
||||
const ref = userStore.cachedUsers.get(params.userId);
|
||||
if (typeof ref === 'undefined') {
|
||||
userReq
|
||||
.getUser(params)
|
||||
.then((args) => {
|
||||
args.ref = userStore.applyUser(args.json);
|
||||
resolve(args);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
resolve({
|
||||
cache: true,
|
||||
json: ref,
|
||||
params,
|
||||
ref
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @type {import('../types/api/user').GetUsers}
|
||||
*/
|
||||
@@ -82,7 +56,6 @@ const userReq = {
|
||||
* @returns {Promise<{json: any, params: {tags: string[]}}>}
|
||||
*/
|
||||
addUserTags(params) {
|
||||
const userStore = useUserStore();
|
||||
return request(`users/${getCurrentUserId()}/addTags`, {
|
||||
method: 'POST',
|
||||
params
|
||||
@@ -91,7 +64,7 @@ const userReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
userStore.applyCurrentUser(json);
|
||||
applyCurrentUser(json);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -101,7 +74,6 @@ const userReq = {
|
||||
* @returns {Promise<{json: any, params: {tags: string[]}}>}
|
||||
*/
|
||||
removeUserTags(params) {
|
||||
const userStore = useUserStore();
|
||||
return request(`users/${getCurrentUserId()}/removeTags`, {
|
||||
method: 'POST',
|
||||
params
|
||||
@@ -110,7 +82,7 @@ const userReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
userStore.applyCurrentUser(json);
|
||||
applyCurrentUser(json);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -139,7 +111,6 @@ const userReq = {
|
||||
* @type {import('../types/api/user').GetCurrentUser}
|
||||
*/
|
||||
saveCurrentUser(params) {
|
||||
const userStore = useUserStore();
|
||||
return request(`users/${getCurrentUserId()}`, {
|
||||
method: 'PUT',
|
||||
params
|
||||
@@ -147,8 +118,17 @@ const userReq = {
|
||||
const args = {
|
||||
json,
|
||||
params,
|
||||
ref: userStore.applyCurrentUser(json)
|
||||
ref: applyCurrentUser(json)
|
||||
};
|
||||
patchAndRefetchActiveQuery({
|
||||
queryKey: queryKeys.user(args.ref.id),
|
||||
nextData: args
|
||||
}).catch((err) => {
|
||||
console.error(
|
||||
'Failed to refresh user query after mutation:',
|
||||
err
|
||||
);
|
||||
});
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
+18
-1
@@ -1,4 +1,19 @@
|
||||
import { request } from '../service/request';
|
||||
import { queryClient } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function refetchActiveGalleryQueries() {
|
||||
queryClient
|
||||
.invalidateQueries({
|
||||
queryKey: ['gallery'],
|
||||
refetchType: 'active'
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to refresh gallery queries:', err);
|
||||
});
|
||||
}
|
||||
|
||||
const VRCPlusIconsReq = {
|
||||
getFileList(params) {
|
||||
@@ -22,6 +37,7 @@ const VRCPlusIconsReq = {
|
||||
json,
|
||||
fileId
|
||||
};
|
||||
refetchActiveGalleryQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -40,6 +56,7 @@ const VRCPlusIconsReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGalleryQueries();
|
||||
return args;
|
||||
});
|
||||
}
|
||||
|
||||
+24
-1
@@ -1,9 +1,27 @@
|
||||
import { request } from '../service/request';
|
||||
import { queryClient } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
import { useUserStore } from '../stores';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function getCurrentUserId() {
|
||||
return useUserStore().currentUser.id;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function refetchActiveGalleryQueries() {
|
||||
queryClient
|
||||
.invalidateQueries({
|
||||
queryKey: ['gallery'],
|
||||
refetchType: 'active'
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to refresh gallery queries:', err);
|
||||
});
|
||||
}
|
||||
const vrcPlusImageReq = {
|
||||
uploadGalleryImage(imageData) {
|
||||
const params = {
|
||||
@@ -19,6 +37,7 @@ const vrcPlusImageReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGalleryQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -34,6 +53,7 @@ const vrcPlusImageReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGalleryQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -59,6 +79,7 @@ const vrcPlusImageReq = {
|
||||
json,
|
||||
printId
|
||||
};
|
||||
refetchActiveGalleryQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -74,6 +95,7 @@ const vrcPlusImageReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGalleryQueries();
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -101,6 +123,7 @@ const vrcPlusImageReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
refetchActiveGalleryQueries();
|
||||
return args;
|
||||
});
|
||||
}
|
||||
|
||||
+35
-39
@@ -1,12 +1,12 @@
|
||||
import { request } from '../service/request';
|
||||
import { useWorldStore } from '../stores';
|
||||
import { patchAndRefetchActiveQuery, queryKeys } from '../queries';
|
||||
import { request } from '../services/request';
|
||||
import { applyWorld } from '../coordinators/worldCoordinator';
|
||||
|
||||
const worldReq = {
|
||||
/**
|
||||
* @type {import('../types/api/world').GetWorld}
|
||||
*/
|
||||
getWorld(params) {
|
||||
const worldStore = useWorldStore();
|
||||
return request(`worlds/${params.worldId}`, {
|
||||
method: 'GET'
|
||||
}).then((json) => {
|
||||
@@ -14,43 +14,15 @@ const worldReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
args.ref = worldStore.applyWorld(json);
|
||||
args.ref = applyWorld(json);
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{worldId: string}} params
|
||||
* @returns {Promise<{json: any, ref: any, cache?: boolean, params}>}
|
||||
*/
|
||||
getCachedWorld(params) {
|
||||
const worldStore = useWorldStore();
|
||||
return new Promise((resolve, reject) => {
|
||||
const ref = worldStore.cachedWorlds.get(params.worldId);
|
||||
if (typeof ref === 'undefined') {
|
||||
worldReq
|
||||
.getWorld(params)
|
||||
.then((args) => {
|
||||
args.ref = worldStore.applyWorld(args.json);
|
||||
resolve(args);
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
resolve({
|
||||
cache: true,
|
||||
json: ref,
|
||||
params,
|
||||
ref
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @type {import('../types/api/world').GetWorlds}
|
||||
*/
|
||||
getWorlds(params, option) {
|
||||
const worldStore = useWorldStore();
|
||||
let endpoint = 'worlds';
|
||||
if (typeof option !== 'undefined') {
|
||||
endpoint = `worlds/${option}`;
|
||||
@@ -65,7 +37,7 @@ const worldReq = {
|
||||
option
|
||||
};
|
||||
for (const json of args.json) {
|
||||
worldStore.applyWorld(json);
|
||||
applyWorld(json);
|
||||
}
|
||||
return args;
|
||||
});
|
||||
@@ -90,7 +62,6 @@ const worldReq = {
|
||||
* @type {import('../types/api/world').SaveWorld}
|
||||
*/
|
||||
saveWorld(params) {
|
||||
const worldStore = useWorldStore();
|
||||
return request(`worlds/${params.id}`, {
|
||||
method: 'PUT',
|
||||
params
|
||||
@@ -99,7 +70,16 @@ const worldReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
args.ref = worldStore.applyWorld(json);
|
||||
args.ref = applyWorld(json);
|
||||
patchAndRefetchActiveQuery({
|
||||
queryKey: queryKeys.world(args.ref.id),
|
||||
nextData: args
|
||||
}).catch((err) => {
|
||||
console.error(
|
||||
'Failed to refresh world query after mutation:',
|
||||
err
|
||||
);
|
||||
});
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -109,7 +89,6 @@ const worldReq = {
|
||||
* @returns {Promise<{json: any, params}>}
|
||||
*/
|
||||
publishWorld(params) {
|
||||
const worldStore = useWorldStore();
|
||||
return request(`worlds/${params.worldId}/publish`, {
|
||||
method: 'PUT',
|
||||
params
|
||||
@@ -118,7 +97,16 @@ const worldReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
args.ref = worldStore.applyWorld(json);
|
||||
args.ref = applyWorld(json);
|
||||
patchAndRefetchActiveQuery({
|
||||
queryKey: queryKeys.world(args.ref.id),
|
||||
nextData: args
|
||||
}).catch((err) => {
|
||||
console.error(
|
||||
'Failed to refresh world query after publish:',
|
||||
err
|
||||
);
|
||||
});
|
||||
return args;
|
||||
});
|
||||
},
|
||||
@@ -128,7 +116,6 @@ const worldReq = {
|
||||
* @returns {Promise<{json: any, params}>}
|
||||
*/
|
||||
unpublishWorld(params) {
|
||||
const worldStore = useWorldStore();
|
||||
return request(`worlds/${params.worldId}/publish`, {
|
||||
method: 'DELETE',
|
||||
params
|
||||
@@ -137,7 +124,16 @@ const worldReq = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
args.ref = worldStore.applyWorld(json);
|
||||
args.ref = applyWorld(json);
|
||||
patchAndRefetchActiveQuery({
|
||||
queryKey: queryKeys.world(args.ref.id),
|
||||
nextData: args
|
||||
}).catch((err) => {
|
||||
console.error(
|
||||
'Failed to refresh world query after unpublish:',
|
||||
err
|
||||
);
|
||||
});
|
||||
return args;
|
||||
});
|
||||
},
|
||||
|
||||
-339
@@ -1,339 +0,0 @@
|
||||
html {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lucide.is-loading {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
.x-app {
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.x-container {
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
overflow: hidden auto;
|
||||
box-sizing: border-box;
|
||||
background: var(--background);
|
||||
height: calc(100vh - 20px);
|
||||
margin: 10px 0 10px 0;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.aside-collapsed .x-container {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
html.dark .x-container {
|
||||
background: var(--sidebar);
|
||||
}
|
||||
|
||||
|
||||
.x-friend-list {
|
||||
padding: 0 10px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
.x-friend-group > .rotation-transition {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.x-dialog .x-friend-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.x-friend-list > .x-friend-group {
|
||||
padding: 16px 0 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.x-friend-item {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-right: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.x-friend-item > img.avatar,
|
||||
img.friends-list-avatar {
|
||||
width: unset;
|
||||
height: 22.5px;
|
||||
margin-right: 0;
|
||||
margin-left: 5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.active > img {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.x-friend-item:hover > .avatar.offline > img,
|
||||
.x-friend-item:hover > .avatar.active > img {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.online.mobile > img,
|
||||
.x-friend-item > .avatar.joinme.mobile > img,
|
||||
.x-friend-item > .avatar.askme.mobile > img,
|
||||
.x-friend-item > .avatar.busy.mobile > img {
|
||||
mask-image: url(/images/masks/usercutoutmobile.svg);
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.online.mobile::after,
|
||||
.x-friend-item > .avatar.joinme.mobile::after,
|
||||
.x-friend-item > .avatar.askme.mobile::after,
|
||||
.x-friend-item > .avatar.busy.mobile::after {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: 0px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
content: '';
|
||||
border-radius: 0px;
|
||||
mask-image: url(/images/masks/phone.svg);
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.active > img,
|
||||
.x-friend-item > .avatar.online > img,
|
||||
.x-friend-item > .avatar.joinme > img,
|
||||
.x-friend-item > .avatar.askme > img,
|
||||
.x-friend-item > .avatar.busy > img,
|
||||
.x-friend-item > .avatar.offline > img {
|
||||
mask-image: url(/images/masks/usercutout.svg);
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.active::after,
|
||||
.x-friend-item > .avatar.online::after,
|
||||
.x-friend-item > .avatar.joinme::after,
|
||||
.x-friend-item > .avatar.askme::after,
|
||||
.x-friend-item > .avatar.busy::after,
|
||||
.x-friend-item > .avatar.offline::after {
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
content: '';
|
||||
background: #909399;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.active::after {
|
||||
background: #f4e05e;
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.online::after {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.joinme::after {
|
||||
background: #409eff;
|
||||
mask-image: url(/images/masks/joinme.svg);
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.askme::after {
|
||||
background: #ff9500;
|
||||
mask-image: url(/images/masks/askme.svg);
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.busy::after {
|
||||
background: #ff2c2c;
|
||||
mask-image: url(/images/masks/busy.svg);
|
||||
}
|
||||
|
||||
.x-friend-item > .avatar.offline::after {
|
||||
background: #909399;
|
||||
}
|
||||
|
||||
.x-friend-item.offline > .avatar::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.x-friend-item > .detail {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.x-friend-item > .detail > .name,
|
||||
.x-friend-item > .detail > .extra {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.x-friend-item > .detail > .name {
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.x-friend-item > .detail > .extra {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.x-friend-item > .detail > .extra > span > span:first-child {
|
||||
scale: 0.9;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
|
||||
.x-friend-item:hover {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.x-friend-item-no-hover:hover {
|
||||
background: unset !important;
|
||||
}
|
||||
|
||||
.x-friend-item-border:hover {
|
||||
border-top-left-radius: 25px;
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
border-bottom-left-radius: 25px;
|
||||
}
|
||||
|
||||
.x-dialog .x-friend-item {
|
||||
width: 167px;
|
||||
}
|
||||
|
||||
|
||||
i.x-user-status,
|
||||
i.x-status-icon {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #808080;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
i.x-user-status.active {
|
||||
background: #f4e05e;
|
||||
}
|
||||
|
||||
i.x-user-status.online {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
i.x-user-status.joinme {
|
||||
background: #409eff;
|
||||
mask-image: url(/images/masks/joinme.svg);
|
||||
}
|
||||
|
||||
i.x-user-status.askme {
|
||||
background: #ff9500;
|
||||
mask-image: url(/images/masks/askme.svg);
|
||||
}
|
||||
|
||||
i.x-user-status.busy {
|
||||
background: #ff2c2c;
|
||||
mask-image: url(/images/masks/busy.svg);
|
||||
}
|
||||
|
||||
i.x-status-icon.green {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
i.x-status-icon.blue {
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
i.x-status-icon.orange {
|
||||
background: #ff9500;
|
||||
}
|
||||
|
||||
i.x-status-icon.red {
|
||||
background: #ff2c2c;
|
||||
}
|
||||
|
||||
.x-tag-platform-pc {
|
||||
color: #0078d4;
|
||||
border-color: #0078d4 !important;
|
||||
}
|
||||
|
||||
.x-tag-platform-quest {
|
||||
color: #3ddc84;
|
||||
border-color: #3ddc84 !important;
|
||||
}
|
||||
|
||||
.x-tag-friend {
|
||||
color: var(--color-amber-400);
|
||||
border-color: var(--color-amber-400) !important;
|
||||
}
|
||||
|
||||
.x-tag-age-verification {
|
||||
color: #3b82f6;
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
.x-tag-border-left {
|
||||
border-left: 0.8px solid;
|
||||
margin-left: 5px;
|
||||
padding-left: 5px;
|
||||
padding-bottom: 0.5px;
|
||||
}
|
||||
|
||||
.options-container {
|
||||
margin-top: 30px;
|
||||
padding: 0 10px 10px 10px;
|
||||
}
|
||||
|
||||
.options-container .header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.options-container .header {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.options-container .sub-header {
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.options-container-item {
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.options-container-item .name {
|
||||
display: inline-block;
|
||||
width: 235px;
|
||||
}
|
||||
|
||||
.x-app > .x-container {
|
||||
padding-top: 15px;
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,3 +1,4 @@
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query';
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import {
|
||||
@@ -6,8 +7,9 @@ import {
|
||||
initPlugins,
|
||||
initRouter,
|
||||
initSentry
|
||||
} from './plugin';
|
||||
} from './plugins';
|
||||
import { initPiniaPlugins, pinia } from './stores';
|
||||
import { queryClient } from './queries';
|
||||
|
||||
import App from './App.vue';
|
||||
|
||||
@@ -18,7 +20,7 @@ await initPiniaPlugins();
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(pinia).use(i18n);
|
||||
app.use(pinia).use(i18n).use(VueQueryPlugin, { queryClient });
|
||||
initComponents(app);
|
||||
initRouter(app);
|
||||
await initSentry(app);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<template>
|
||||
<div @click="confirm" class="cursor-pointer w-fit align-top flex items-center">
|
||||
<span class="flex items-center"
|
||||
>{{ avatarName }} <Lock v-if="avatarType && avatarType === '(own)'" class="h-4 w-4 mx-1"
|
||||
<span v-if="avatarName" class="flex items-center mr-1"
|
||||
>{{ avatarName }} <Lock v-if="avatarType && avatarType === '(own)'" class="h-4 w-4 ml-1"
|
||||
/></span>
|
||||
<span v-else class="flex items-center mr-1 text-muted-foreground">{{
|
||||
t('dialog.user.info.unknown_avatar')
|
||||
}}</span>
|
||||
<TooltipWrapper v-if="avatarTags">
|
||||
<template #content>
|
||||
<span>{{ avatarTags }}</span>
|
||||
<span class="truncate">{{ avatarTags }}</span>
|
||||
</template>
|
||||
<span v-if="avatarTags" style="font-size: 12px" class="truncate">{{ avatarTags }}</span>
|
||||
<span class="truncate text-xs text-muted-foreground">{{ avatarTags }}</span>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,11 +18,12 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { Lock } from 'lucide-vue-next';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { TooltipWrapper } from './ui/tooltip';
|
||||
import { useAvatarStore } from '../stores';
|
||||
import { getAvatarName, showAvatarAuthorDialog } from '../coordinators/avatarCoordinator';
|
||||
|
||||
const avatarStore = useAvatarStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
imageurl: String,
|
||||
@@ -49,7 +53,7 @@
|
||||
ownerId = props.hintownerid;
|
||||
} else {
|
||||
try {
|
||||
const info = await avatarStore.getAvatarName(props.imageurl);
|
||||
const info = await getAvatarName(props.imageurl);
|
||||
avatarName.value = info.avatarName;
|
||||
ownerId = info.ownerId;
|
||||
} catch {
|
||||
@@ -72,7 +76,7 @@
|
||||
|
||||
const confirm = () => {
|
||||
if (!props.imageurl) return;
|
||||
avatarStore.showAvatarAuthorDialog(props.userid, ownerId, props.imageurl);
|
||||
showAvatarAuthorDialog(props.userid, ownerId, props.imageurl);
|
||||
};
|
||||
|
||||
watch([() => props.imageurl, () => props.userid, () => props.avatartags], parse, { immediate: true });
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ArrowUp } from 'lucide-vue-next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
// scroll DOM ref
|
||||
target: { type: [String, Object], default: null },
|
||||
// @tanstack/virtual instance
|
||||
virtualizer: { type: [Object], default: null },
|
||||
|
||||
bottom: { type: Number, default: 20 },
|
||||
right: { type: Number, default: 20 },
|
||||
|
||||
visibilityHeight: { type: Number, default: 200 },
|
||||
visibilityHeight: { type: Number, default: 400 },
|
||||
|
||||
behavior: {
|
||||
type: String,
|
||||
@@ -21,28 +24,26 @@
|
||||
tooltip: { type: Boolean, default: true },
|
||||
tooltipText: { type: String, default: 'Back to top' },
|
||||
|
||||
teleport: { type: Boolean, default: true }
|
||||
teleport: { type: Boolean, default: true },
|
||||
teleportTo: { type: [Boolean, String, Object], default: null }
|
||||
});
|
||||
|
||||
const visible = ref(false);
|
||||
let containerEl = null;
|
||||
|
||||
function resolveTarget() {
|
||||
if (!props.target) return null;
|
||||
if (typeof props.target === 'string') {
|
||||
return document.querySelector(props.target);
|
||||
function resolveElement(target) {
|
||||
if (!target) return null;
|
||||
if (typeof target === 'string') return document.querySelector(target);
|
||||
if (typeof target === 'object') {
|
||||
if ('value' in target) return target.value;
|
||||
if ('$el' in target) return target.$el;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
if (typeof props.target === 'object') {
|
||||
if ('value' in props.target) {
|
||||
return props.target.value;
|
||||
}
|
||||
if ('$el' in props.target) {
|
||||
return props.target.$el;
|
||||
}
|
||||
}
|
||||
|
||||
return props.target;
|
||||
function getVirtualizer() {
|
||||
if (!props.virtualizer) return null;
|
||||
return 'value' in props.virtualizer ? props.virtualizer.value : props.virtualizer;
|
||||
}
|
||||
|
||||
function getScrollTop() {
|
||||
@@ -58,15 +59,21 @@
|
||||
|
||||
function scrollToTop() {
|
||||
const behavior = props.behavior === 'auto' ? 'auto' : 'smooth';
|
||||
if (!containerEl || typeof containerEl.scrollTo !== 'function') {
|
||||
window.scrollTo({ top: 0, behavior });
|
||||
const v = getVirtualizer();
|
||||
if (v?.scrollToIndex) {
|
||||
v.scrollToIndex(0, { align: 'start', behavior: 'auto' });
|
||||
return;
|
||||
}
|
||||
containerEl.scrollTo({ top: 0, behavior });
|
||||
const target = containerEl || resolveElement(props.target);
|
||||
if (target && typeof target.scrollTo === 'function') {
|
||||
target.scrollTo({ top: 0, behavior });
|
||||
return;
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior });
|
||||
}
|
||||
|
||||
function bind() {
|
||||
containerEl = resolveTarget();
|
||||
containerEl = resolveElement(props.target);
|
||||
|
||||
const target = containerEl && typeof containerEl.addEventListener === 'function' ? containerEl : window;
|
||||
target.addEventListener('scroll', handleScroll, { passive: true });
|
||||
@@ -94,32 +101,42 @@
|
||||
unbind();
|
||||
});
|
||||
|
||||
const teleportTarget = computed(() => {
|
||||
if (props.teleportTo !== null && props.teleportTo !== undefined) {
|
||||
if (props.teleportTo === true) return 'body';
|
||||
if (props.teleportTo === false) return null;
|
||||
return resolveElement(props.teleportTo);
|
||||
}
|
||||
return props.teleport ? 'body' : null;
|
||||
});
|
||||
|
||||
const isBodyTeleport = computed(() => teleportTarget.value === 'body' || teleportTarget.value === document.body);
|
||||
|
||||
const wrapperStyle = computed(
|
||||
() => `position:fixed; right:${props.right}px; bottom:${props.bottom}px; z-index:50;`
|
||||
() =>
|
||||
`position:${isBodyTeleport.value ? 'fixed' : 'absolute'}; right:${props.right}px; bottom:${props.bottom}px; z-index:50;`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="teleport" to="body">
|
||||
<Teleport v-if="teleportTarget" :to="teleportTarget">
|
||||
<Transition name="back-to-top">
|
||||
<div v-if="visible" :style="wrapperStyle">
|
||||
<TooltipProvider v-if="tooltip">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
class="rounded-full shadow"
|
||||
aria-label="Back to top"
|
||||
@click="scrollToTop">
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{{ tooltipText }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Tooltip v-if="tooltip">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
class="rounded-full shadow"
|
||||
aria-label="Back to top"
|
||||
@click="scrollToTop">
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{{ tooltipText }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
@@ -136,7 +153,24 @@
|
||||
|
||||
<Transition v-else name="back-to-top">
|
||||
<div v-if="visible" :style="wrapperStyle">
|
||||
<Tooltip v-if="tooltip">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
class="rounded-full shadow"
|
||||
aria-label="Back to top"
|
||||
@click="scrollToTop">
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{{ tooltipText }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
class="rounded-full shadow"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<Alert variant="warning" class="mb-4">
|
||||
<MessageSquareWarning />
|
||||
<AlertTitle>{{ t('common.feature_relocated.title') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<i18n-t keypath="common.feature_relocated.description" tag="span">
|
||||
<template #feature>
|
||||
<strong>{{ featureName }}</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { MessageSquareWarning } from 'lucide-vue-next';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from './ui/alert';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps({
|
||||
featureName: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<span @click="showUserDialog" class="cursor-pointer">{{ username }}</span>
|
||||
<span @click="openUserDialog" class="cursor-pointer">{{ username }}</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { useUserStore } from '../stores';
|
||||
import { userRequest } from '../api';
|
||||
|
||||
const userStore = useUserStore();
|
||||
import { queryRequest } from '../api';
|
||||
import { showUserDialog } from '../coordinators/userCoordinator';
|
||||
|
||||
const props = defineProps({
|
||||
userid: String,
|
||||
@@ -22,20 +20,26 @@
|
||||
|
||||
const username = ref(props.userid);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function parse() {
|
||||
username.value = props.userid;
|
||||
if (props.hint) {
|
||||
username.value = props.hint;
|
||||
} else if (props.userid) {
|
||||
const args = await userRequest.getCachedUser({ userId: props.userid });
|
||||
const args = await queryRequest.fetch('user.dialog', { userId: props.userid });
|
||||
if (args?.json?.displayName) {
|
||||
username.value = args.json.displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showUserDialog() {
|
||||
userStore.showUserDialog(props.userid);
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function openUserDialog() {
|
||||
showUserDialog(props.userid);
|
||||
}
|
||||
|
||||
watch([() => props.userid, () => props.location, () => props.forceUpdateKey], parse, { immediate: true });
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<template>
|
||||
<div style="overflow: hidden" :style="{ width: size + 'px', height: size + 'px' }">
|
||||
<div ref="containerRef" class="relative overflow-hidden" :style="sizeStyle">
|
||||
<div
|
||||
v-if="image.frames"
|
||||
class="avatar"
|
||||
:style="generateEmojiStyle(imageUrl, image.framesOverTime, image.frames, image.loopStyle, size)"></div>
|
||||
<img v-else :src="imageUrl" class="avatar" :style="{ width: size + 'px', height: size + 'px' }" />
|
||||
class="avatar absolute top-0 left-0"
|
||||
:style="generateEmojiStyle(imageUrl, image.framesOverTime, image.frames, image.loopStyle, effectiveSize)"></div>
|
||||
<Avatar v-else class="rounded w-full h-full">
|
||||
<AvatarImage :src="imageUrl" class="object-cover" />
|
||||
<AvatarFallback class="rounded">
|
||||
<Image class="size-4 text-muted-foreground" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import { Image } from 'lucide-vue-next';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
||||
import { extractFileId, generateEmojiStyle } from '../shared/utils';
|
||||
import { useGalleryStore } from '../stores';
|
||||
|
||||
@@ -19,7 +27,31 @@
|
||||
|
||||
const props = defineProps({
|
||||
imageUrl: { type: String, default: '' },
|
||||
size: { type: Number, default: 100 }
|
||||
size: { type: Number, default: 0 }
|
||||
});
|
||||
|
||||
const containerRef = ref(null);
|
||||
const observedWidth = ref(0);
|
||||
|
||||
useResizeObserver(containerRef, (entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
observedWidth.value = entry.contentRect.width;
|
||||
}
|
||||
});
|
||||
|
||||
const effectiveSize = computed(() => {
|
||||
if (props.size > 0) {
|
||||
return props.size;
|
||||
}
|
||||
return observedWidth.value;
|
||||
});
|
||||
|
||||
const sizeStyle = computed(() => {
|
||||
if (props.size > 0) {
|
||||
return { width: props.size + 'px', height: props.size + 'px' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const image = ref({
|
||||
@@ -37,6 +69,7 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!fileId) return;
|
||||
image.value = await getCachedEmoji(fileId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogPortal :to="portalTo">
|
||||
<RekaDialogOverlay class="fixed inset-0 bg-background/80 backdrop-blur-sm" @click="closeDialog" />
|
||||
<RekaDialogOverlay
|
||||
:class="cn('fixed inset-0 bg-background/80', !disableGpuAcceleration && 'backdrop-blur-sm')" />
|
||||
|
||||
<RekaDialogContent
|
||||
class="fixed inset-0 p-6 sm:p-10 border-0 bg-transparent shadow-none outline-none"
|
||||
@click="closeDialog"
|
||||
@open-auto-focus.prevent
|
||||
@close-auto-focus.prevent>
|
||||
<div ref="viewerEl" class="relative h-full w-full overflow-hidden select-none">
|
||||
<!-- toolbar -->
|
||||
<div
|
||||
@click.stop
|
||||
class="absolute right-3 top-3 z-10 flex items-center gap-2 rounded-md bg-background/70 backdrop-blur px-2 py-1 border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -73,14 +76,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-full w-full flex items-center justify-center"
|
||||
@wheel="onWheel"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp">
|
||||
<div class="h-full w-full flex items-center justify-center" @wheel="onWheel">
|
||||
<img
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@click.stop
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
class="max-h-full max-w-full x-viewer-img"
|
||||
@@ -95,21 +97,25 @@
|
||||
|
||||
<script setup>
|
||||
import { Copy, Download, RefreshCcw, RotateCcw, RotateCw, X, ZoomIn, ZoomOut } from 'lucide-vue-next';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { DialogContent as RekaDialogContent, DialogOverlay as RekaDialogOverlay, DialogPortal } from 'reka-ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog } from '@/components/ui/dialog';
|
||||
import { acquireModalPortalLayer } from '@/lib/modalPortalLayers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useGeneralSettingsStore } from '@/stores/settings/general';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Noty from 'noty';
|
||||
|
||||
import { escapeTag, extractFileId } from '../shared/utils';
|
||||
import { extractFileId } from '../shared/utils';
|
||||
import { useGalleryStore } from '../stores';
|
||||
|
||||
const galleryStore = useGalleryStore();
|
||||
const { fullscreenImageDialog } = storeToRefs(galleryStore);
|
||||
const { disableGpuAcceleration } = storeToRefs(useGeneralSettingsStore());
|
||||
const { t } = useI18n();
|
||||
|
||||
const viewerEl = ref(null);
|
||||
const portalLayer = acquireModalPortalLayer();
|
||||
@@ -280,12 +286,11 @@
|
||||
else if (e.key.toLowerCase() === 'r') rotateCW();
|
||||
else if (e.key === '0') resetTransform();
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
|
||||
useEventListener(window, 'keydown', onKeydown);
|
||||
|
||||
async function copyImageToClipboard(url) {
|
||||
if (!url) return;
|
||||
const msg = toast.info('Downloading image...');
|
||||
const msg = toast.info(t('message.image.downloading'));
|
||||
try {
|
||||
const response = await webApiService.execute({ url, method: 'GET' });
|
||||
if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) {
|
||||
@@ -293,10 +298,10 @@
|
||||
}
|
||||
const blob = await (await fetch(response.data)).blob();
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
toast.success('Image copied to clipboard');
|
||||
toast.success(t('message.image.copied_to_clipboard'));
|
||||
} catch (error) {
|
||||
console.error('Error downloading image:', error);
|
||||
new Noty({ type: 'error', text: escapeTag(`Failed to download image. ${url}`) }).show();
|
||||
toast.error(`Failed to download image. ${url}`);
|
||||
} finally {
|
||||
toast.dismiss(msg);
|
||||
}
|
||||
@@ -304,7 +309,7 @@
|
||||
|
||||
async function downloadAndSaveImage(url, fileName) {
|
||||
if (!url) return;
|
||||
const msg = toast.info('Downloading image...');
|
||||
const msg = toast.info(t('message.image.downloading'));
|
||||
try {
|
||||
const response = await webApiService.execute({ url, method: 'GET' });
|
||||
if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) {
|
||||
@@ -326,7 +331,7 @@
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Error downloading image:', error);
|
||||
new Noty({ type: 'error', text: escapeTag(`Failed to download image. ${url}`) }).show();
|
||||
toast.error(`Failed to download image. ${url}`);
|
||||
} finally {
|
||||
toast.dismiss(msg);
|
||||
}
|
||||
|
||||
@@ -53,22 +53,14 @@
|
||||
</TooltipWrapper>
|
||||
<TooltipWrapper v-if="showHistoryButton" side="top" :content="historyTooltip">
|
||||
<Button
|
||||
class="rounded-full w-6 h-6 text-xs text-muted-foreground hover:text-foreground"
|
||||
class="rounded-full w-6 h-6 text-xs text-muted-foreground hover:text-foreground ml-1.5"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
style="margin-left: 5px"
|
||||
@click="handleHistory">
|
||||
<History class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipWrapper>
|
||||
<span v-if="showLastJoinIndicator" class="inline-block ml-2">
|
||||
<TooltipWrapper side="top" class="ml-5">
|
||||
<template #content>
|
||||
<span>{{ t('dialog.user.info.last_join') }} <Timer :epoch="lastJoin" /></span>
|
||||
</template>
|
||||
<MapPin class="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
|
||||
<div v-if="showInstanceInfo" class="flex items-center ml-2">
|
||||
<TooltipWrapper v-if="instanceInfoState.isValidInstance" side="top">
|
||||
<template #content>
|
||||
@@ -79,7 +71,7 @@
|
||||
<template v-if="instanceInfoState.canCloseInstance">
|
||||
<Button
|
||||
class="mt-1"
|
||||
size="sm"
|
||||
size="xs"
|
||||
:disabled="!!instance?.closedAt"
|
||||
@click="closeInstance(resolvedInstanceLocation)">
|
||||
{{ t('dialog.user.info.close_instance') }}
|
||||
@@ -87,11 +79,12 @@
|
||||
<br /><br />
|
||||
</template>
|
||||
<span>
|
||||
<span class="x-tag-platform-pc">PC: </span>{{ instance?.platforms?.standalonewindows }}
|
||||
<span class="text-platform-pc border-platform-pc!">PC: </span
|
||||
>{{ instance?.platforms?.standalonewindows }}
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
<span class="x-tag-platform-quest">Android: </span>{{ instance?.platforms?.android }}
|
||||
<span class="text-platform-quest border-platform-quest!">Android: </span
|
||||
>{{ instance?.platforms?.android }}
|
||||
</span>
|
||||
<br />
|
||||
<span><span>iOS: </span>{{ instance?.platforms?.ios }}</span>
|
||||
@@ -107,32 +100,44 @@
|
||||
</span>
|
||||
<span v-if="instance?.users?.length">{{ t('dialog.user.info.instance_users') }}<br /></span>
|
||||
<template v-for="user in instance?.users || []" :key="user.id">
|
||||
<span style="cursor: pointer; margin-right: 5px" @click="showUserDialog(user.id)">
|
||||
<span style="cursor: pointer; margin-right: 6px" @click="showUserDialog(user.id)">
|
||||
{{ user.displayName }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mr-2 text-muted-foreground">
|
||||
<div class="mr-1 text-muted-foreground">
|
||||
<span v-if="resolvedInstanceLocation === locationStore.lastLocation.location">
|
||||
{{ locationStore.lastLocation.playerList.size }}/{{ instance?.capacity }}
|
||||
</span>
|
||||
|
||||
<span v-else-if="instance?.userCount"> {{ instance.userCount }}/{{ instance?.capacity }} </span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<span v-if="friendcount" class="ml-1 flex items-center text-muted-foreground"
|
||||
><UsersRound />{{ friendcount }}</span
|
||||
>
|
||||
<TooltipWrapper v-if="friendcount" side="top" :content="t('dialog.user.info.instance_friends_tooltip')">
|
||||
<span class="ml-1 flex items-center text-muted-foreground"><UsersRound />{{ friendcount }}</span>
|
||||
</TooltipWrapper>
|
||||
<span v-if="showLastJoinIndicator" class="inline-block ml-1">
|
||||
<TooltipWrapper side="top">
|
||||
<template #content>
|
||||
<span>{{ t('dialog.user.info.last_join') }} </span>
|
||||
</template>
|
||||
<span class="flex items-center ml-1">
|
||||
<MapPin class="h-4 w-4 text-muted-foreground" />
|
||||
<Timer class="text-muted-foreground" :epoch="lastJoin" />
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
</span>
|
||||
<span v-if="instanceInfoState.isValidInstance && !instance?.hasCapacityForYou" class="ml-1">
|
||||
{{ t('dialog.user.info.instance_full') }}
|
||||
</span>
|
||||
<span v-if="instance?.queueSize" class="ml-1">
|
||||
{{ t('dialog.user.info.instance_queue') }} {{ instance.queueSize }}
|
||||
</span>
|
||||
<span v-if="instanceInfoState.isAgeGated" class="ml-1">
|
||||
<Badge v-if="instanceInfoState.isAgeGated" variant="destructive" class="ml-1">
|
||||
{{ t('dialog.user.info.instance_age_gated') }}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,6 +145,7 @@
|
||||
<script setup>
|
||||
import { History, Loader2, LogIn, Mail, MapPin, RefreshCw, UsersRound } from 'lucide-vue-next';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
@@ -154,8 +160,10 @@
|
||||
useModalStore,
|
||||
useUserStore
|
||||
} from '../stores';
|
||||
import { checkCanInviteSelf, formatDateFilter, hasGroupPermission, parseLocation } from '../shared/utils';
|
||||
import { formatDateFilter, hasGroupPermission, parseLocation } from '../shared/utils';
|
||||
import { useInviteChecks } from '../composables/useInviteChecks';
|
||||
import { instanceRequest, miscRequest } from '../api';
|
||||
import { showUserDialog } from '../coordinators/userCoordinator';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
@@ -174,6 +182,7 @@
|
||||
const { instanceJoinHistory } = storeToRefs(instanceStore);
|
||||
const { canOpenInstanceInGame } = storeToRefs(inviteStore);
|
||||
const { isOpeningInstance } = storeToRefs(launchStore);
|
||||
const { checkCanInviteSelf } = useInviteChecks();
|
||||
|
||||
const props = defineProps({
|
||||
location: {
|
||||
@@ -262,7 +271,7 @@
|
||||
const showLaunchButton = computed(() => props.showLaunch && checkCanInviteSelf(resolvedLaunchLocation.value));
|
||||
const showInviteYourself = computed(() => props.showInvite && checkCanInviteSelf(resolvedInviteLocation.value));
|
||||
|
||||
const inviteStyle = computed(() => (showLaunchButton.value ? 'margin-left: 5px' : ''));
|
||||
const inviteStyle = computed(() => (showLaunchButton.value ? 'margin-left: 6px' : ''));
|
||||
const showRefreshButton = computed(() => props.showRefresh && typeof props.onRefresh === 'function');
|
||||
const showHistoryButton = computed(() => props.showHistory && typeof props.onHistory === 'function');
|
||||
|
||||
@@ -305,7 +314,7 @@
|
||||
shortName: props.shortname
|
||||
})
|
||||
.then((args) => {
|
||||
toast.success('Self invite sent');
|
||||
toast.success(t('message.invite.self_sent'));
|
||||
return args;
|
||||
});
|
||||
};
|
||||
@@ -347,15 +356,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
const showUserDialog = (userId) => {
|
||||
userStore.showUserDialog(userId);
|
||||
};
|
||||
|
||||
const closeInstance = (location) => {
|
||||
modalStore
|
||||
.confirm({
|
||||
description: 'Continue? X Instance, nobody will be able to join',
|
||||
title: 'Confirm'
|
||||
description: t('confirm.close_instance'),
|
||||
title: t('confirm.title')
|
||||
})
|
||||
.then(async ({ ok }) => {
|
||||
if (!ok) return;
|
||||
@@ -374,12 +379,3 @@
|
||||
immediate: true
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
.ml-5 {
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+191
-100
@@ -1,33 +1,57 @@
|
||||
<template>
|
||||
<div class="cursor-pointer">
|
||||
<div v-if="!text" class="transparent">-</div>
|
||||
<div v-show="text" class="flex items-center">
|
||||
<div v-if="region" :class="['flags', 'mr-1.5', region]"></div>
|
||||
<TooltipWrapper :content="tooltipContent" :disabled="tooltipDisabled" :delay-duration="300" side="top">
|
||||
<div
|
||||
:class="locationClasses"
|
||||
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden"
|
||||
@click="handleShowWorldDialog">
|
||||
<Spinner v-if="isTraveling" class="mr-1" />
|
||||
<span class="min-w-0 truncate">{{ text }}</span>
|
||||
<span v-if="showInstanceIdInLocation && instanceName" class="ml-1 whitespace-nowrap">{{
|
||||
` · #${instanceName}`
|
||||
}}</span>
|
||||
<span
|
||||
v-if="groupName"
|
||||
class="ml-0.5 whitespace-nowrap cursor-pointer"
|
||||
@click.stop="handleShowGroupDialog">
|
||||
({{ groupName }})
|
||||
</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
<component :is="enableContextMenu ? ContextMenu : Passthrough">
|
||||
<component :is="enableContextMenu ? ContextMenuTrigger : Passthrough" as-child>
|
||||
<div class="cursor-pointer" v-bind="$attrs">
|
||||
<div v-if="!text" class="text-transparent">-</div>
|
||||
<div v-show="text" class="flex items-center">
|
||||
<template v-if="isAgeRestricted">
|
||||
<TooltipWrapper :content="t('dialog.user.info.instance_age_restricted_tooltip')" :delay-duration="300" side="top">
|
||||
<div class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Lock class="size-3.5 shrink-0" />
|
||||
<span>{{ t('dialog.user.info.instance_age_restricted') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="region" :class="['flags', 'mr-1.5', 'shrink-0', region]"></div>
|
||||
<TooltipWrapper :content="tooltipContent" :disabled="tooltipDisabled" :delay-duration="300" side="top">
|
||||
<div
|
||||
:class="locationClasses"
|
||||
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden truncate"
|
||||
@click="handleShowWorldDialog">
|
||||
<Spinner v-if="isTraveling" class="mr-1 shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<span>{{ text }}</span>
|
||||
<span v-if="showInstanceIdInLocation && instanceName" class="ml-1">{{
|
||||
` · #${instanceName}`
|
||||
}}</span>
|
||||
<span v-if="groupName" class="ml-0.5 cursor-pointer" @click.stop="handleShowGroupDialog">
|
||||
({{ groupName }})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper v-if="isClosed" :content="closedTooltip" :disabled="disableTooltip">
|
||||
<AlertTriangle class="inline-block ml-2 text-muted-foreground" />
|
||||
</TooltipWrapper>
|
||||
<Lock v-if="strict" class="inline-block ml-2 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<TooltipWrapper v-if="isClosed" :content="closedTooltip" :disabled="disableTooltip">
|
||||
<AlertTriangle class="inline-block ml-2 text-muted-foreground shrink-0" />
|
||||
</TooltipWrapper>
|
||||
<Lock v-if="strict" class="inline-block ml-2 text-muted-foreground shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
<ContextMenuContent v-if="enableContextMenu && parsedLocation.isRealInstance && parsedLocation.worldId">
|
||||
<WorldActionMenuItems
|
||||
:can-open-instance-in-game="canOpenInstanceInGame"
|
||||
:show-share="true"
|
||||
:show-previous-instances="true"
|
||||
@view-details="handleShowWorldDialog"
|
||||
@share="handleShareLocation"
|
||||
@new-instance="handleNewInstance"
|
||||
@self-invite="handleNewInstanceSelfInvite"
|
||||
@show-previous-instances="handleShowPreviousInstances" />
|
||||
</ContextMenuContent>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -37,25 +61,43 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import {
|
||||
useAppearanceSettingsStore,
|
||||
useGroupStore,
|
||||
useInstanceStore,
|
||||
useSearchStore,
|
||||
useWorldStore
|
||||
} from '../stores';
|
||||
import { getGroupName, getWorldName, parseLocation } from '../shared/utils';
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuTrigger
|
||||
} from './ui/context-menu';
|
||||
|
||||
import {
|
||||
getGroupName,
|
||||
getLocationText,
|
||||
getWorldName,
|
||||
copyToClipboard,
|
||||
parseLocation,
|
||||
resolveRegion,
|
||||
translateAccessType
|
||||
} from '../shared/utils';
|
||||
import { useAppearanceSettingsStore, useInstanceStore, useInviteStore, useSearchStore, useWorldStore } from '../stores';
|
||||
import { showGroupDialog } from '../coordinators/groupCoordinator';
|
||||
import { showWorldDialog } from '../coordinators/worldCoordinator';
|
||||
import { runNewInstanceSelfInviteFlow } from '../coordinators/inviteCoordinator';
|
||||
import { Spinner } from './ui/spinner';
|
||||
import WorldActionMenuItems from './WorldActionMenuItems.vue';
|
||||
import { accessTypeLocaleKeyMap } from '../shared/constants';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
const Passthrough = (_, { slots }) => slots.default?.();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { cachedWorlds, showWorldDialog } = useWorldStore();
|
||||
const { showGroupDialog } = useGroupStore();
|
||||
const { cachedWorlds } = useWorldStore();
|
||||
const { showPreviousInstancesInfoDialog } = useInstanceStore();
|
||||
const { verifyShortName } = useSearchStore();
|
||||
const { cachedInstances } = useInstanceStore();
|
||||
const { lastInstanceApplied } = storeToRefs(useInstanceStore());
|
||||
const { showInstanceIdInLocation } = storeToRefs(useAppearanceSettingsStore());
|
||||
const { canOpenInstanceInGame } = useInviteStore();
|
||||
const { showInstanceIdInLocation, isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
|
||||
|
||||
const props = defineProps({
|
||||
location: String,
|
||||
@@ -79,17 +121,24 @@
|
||||
isOpenPreviousInstanceInfoDialog: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableContextMenu: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const text = ref('');
|
||||
const region = ref('');
|
||||
const strict = ref(false);
|
||||
const ageGate = ref(false);
|
||||
const isTraveling = ref(false);
|
||||
const parsedLocation = ref({ isRealInstance: false, worldId: '', tag: '', shortName: '' });
|
||||
const groupName = ref('');
|
||||
const isClosed = ref(false);
|
||||
const instanceName = ref('');
|
||||
|
||||
const isAgeRestricted = computed(() => !isAgeGatedInstancesVisible.value && ageGate.value);
|
||||
const isLocationLink = computed(() => props.link && props.location !== 'private' && props.location !== 'offline');
|
||||
const locationClasses = computed(() => [
|
||||
'x-location',
|
||||
@@ -98,9 +147,7 @@
|
||||
}
|
||||
]);
|
||||
const tooltipContent = computed(() => `${t('dialog.new_instance.instance_id')}: #${instanceName.value}`);
|
||||
const tooltipDisabled = computed(
|
||||
() => props.disableTooltip || !instanceName.value || showInstanceIdInLocation.value
|
||||
);
|
||||
const tooltipDisabled = computed(() => props.disableTooltip || !instanceName.value || showInstanceIdInLocation.value);
|
||||
const closedTooltip = computed(() => t('dialog.user.info.instance_closed'));
|
||||
|
||||
let isDisposed = false;
|
||||
@@ -119,6 +166,9 @@
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function currentInstanceId() {
|
||||
if (typeof props.traveling !== 'undefined' && props.location === 'traveling') {
|
||||
return props.traveling;
|
||||
@@ -126,16 +176,23 @@
|
||||
return props.location;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function resetState() {
|
||||
text.value = '';
|
||||
region.value = '';
|
||||
strict.value = false;
|
||||
ageGate.value = false;
|
||||
isTraveling.value = false;
|
||||
groupName.value = '';
|
||||
isClosed.value = false;
|
||||
instanceName.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function parse() {
|
||||
if (isDisposed) {
|
||||
return;
|
||||
@@ -148,6 +205,7 @@
|
||||
isTraveling.value = true;
|
||||
}
|
||||
const L = parseLocation(instanceId);
|
||||
parsedLocation.value = L;
|
||||
setText(L);
|
||||
instanceName.value = L.instanceName;
|
||||
if (!L.isRealInstance) {
|
||||
@@ -158,8 +216,13 @@
|
||||
updateGroupName(L, instanceId);
|
||||
updateRegion(L);
|
||||
strict.value = L.strict;
|
||||
ageGate.value = L.ageGate;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function applyInstanceRef(L) {
|
||||
const instanceRef = cachedInstances.get(L.tag);
|
||||
if (typeof instanceRef === 'undefined') {
|
||||
@@ -174,6 +237,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
* @param instanceId
|
||||
*/
|
||||
function updateGroupName(L, instanceId) {
|
||||
if (props.grouphint) {
|
||||
groupName.value = props.grouphint;
|
||||
@@ -190,68 +258,55 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function updateRegion(L) {
|
||||
region.value = '';
|
||||
if (!L.isOffline && !L.isPrivate && !L.isTraveling) {
|
||||
region.value = L.region;
|
||||
if (!L.region && L.instanceId) {
|
||||
region.value = 'us';
|
||||
}
|
||||
}
|
||||
region.value = resolveRegion(L);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param accessTypeName
|
||||
*/
|
||||
function getAccessTypeLabel(accessTypeName) {
|
||||
return translateAccessType(accessTypeName, t, accessTypeLocaleKeyMap);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param L
|
||||
*/
|
||||
function setText(L) {
|
||||
const accessTypeLabel = translateAccessType(L.accessTypeName);
|
||||
const accessTypeLabel = getAccessTypeLabel(L.accessTypeName);
|
||||
const cachedRef = L.worldId ? cachedWorlds.get(L.worldId) : undefined;
|
||||
const worldName = typeof cachedRef !== 'undefined' ? cachedRef.name : undefined;
|
||||
|
||||
if (L.isOffline) {
|
||||
text.value = 'Offline';
|
||||
} else if (L.isPrivate) {
|
||||
text.value = 'Private';
|
||||
} else if (L.isTraveling) {
|
||||
text.value = 'Traveling';
|
||||
} else if (typeof props.hint === 'string' && props.hint !== '') {
|
||||
if (L.instanceId) {
|
||||
text.value = `${props.hint} · ${accessTypeLabel}`;
|
||||
} else {
|
||||
text.value = props.hint;
|
||||
}
|
||||
} else if (L.worldId) {
|
||||
if (L.instanceId) {
|
||||
text.value = `${L.worldId} · ${accessTypeLabel}`;
|
||||
} else {
|
||||
text.value = L.worldId;
|
||||
}
|
||||
const ref = cachedWorlds.get(L.worldId);
|
||||
if (typeof ref === 'undefined') {
|
||||
getWorldName(L.worldId).then((name) => {
|
||||
if (!isDisposed && name && currentInstanceId() === L.tag) {
|
||||
if (L.instanceId) {
|
||||
text.value = `${name} · ${translateAccessType(L.accessTypeName)}`;
|
||||
} else {
|
||||
text.value = name;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (L.instanceId) {
|
||||
text.value = `${ref.name} · ${accessTypeLabel}`;
|
||||
} else {
|
||||
text.value = ref.name;
|
||||
}
|
||||
text.value = getLocationText(L, {
|
||||
hint: props.hint,
|
||||
worldName,
|
||||
accessTypeLabel,
|
||||
t
|
||||
});
|
||||
|
||||
if (L.worldId && typeof cachedRef === 'undefined') {
|
||||
getWorldName(L.worldId).then((name) => {
|
||||
if (!isDisposed && name && currentInstanceId() === L.tag) {
|
||||
text.value = getLocationText(L, {
|
||||
hint: props.hint,
|
||||
worldName: name,
|
||||
accessTypeLabel: getAccessTypeLabel(L.accessTypeName),
|
||||
t
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function translateAccessType(accessTypeNameRaw) {
|
||||
const key = accessTypeLocaleKeyMap[accessTypeNameRaw];
|
||||
if (!key) {
|
||||
return accessTypeNameRaw;
|
||||
}
|
||||
if (accessTypeNameRaw === 'groupPublic' || accessTypeNameRaw === 'groupPlus') {
|
||||
const groupKey = accessTypeLocaleKeyMap['group'];
|
||||
return t(groupKey) + ' ' + t(key);
|
||||
}
|
||||
return t(key);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShowWorldDialog() {
|
||||
if (props.link) {
|
||||
let instanceId = currentInstanceId();
|
||||
@@ -267,6 +322,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShowGroupDialog() {
|
||||
let location = currentInstanceId();
|
||||
if (!location) {
|
||||
@@ -278,10 +336,43 @@
|
||||
}
|
||||
showGroupDialog(L.groupId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transparent {
|
||||
color: transparent;
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShareLocation() {
|
||||
const L = parsedLocation.value;
|
||||
if (!L.worldId) return;
|
||||
copyToClipboard(
|
||||
`https://vrchat.com/home/world/${L.worldId}`,
|
||||
t('message.world.url_copied')
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleNewInstance() {
|
||||
const L = parsedLocation.value;
|
||||
if (!L.worldId) return;
|
||||
showWorldDialog(L.tag, L.shortName);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleNewInstanceSelfInvite() {
|
||||
const L = parsedLocation.value;
|
||||
if (!L.worldId) return;
|
||||
runNewInstanceSelfInviteFlow(L.worldId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleShowPreviousInstances() {
|
||||
const instanceId = currentInstanceId();
|
||||
if (!instanceId) return;
|
||||
showPreviousInstancesInfoDialog(instanceId);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<Unlock v-if="isUnlocked" :class="['inline-block', 'mr-1.25']" />
|
||||
<span> {{ accessTypeName }} #{{ instanceName }}</span>
|
||||
</span>
|
||||
<span v-if="groupName" @click="showGroupDialog" class="cursor-pointer">({{ groupName }})</span>
|
||||
<span v-if="groupName" @click="openLocationGroupDialog" class="cursor-pointer">({{ groupName }})</span>
|
||||
<TooltipWrapper v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
|
||||
<AlertTriangle :class="['inline-block', 'ml-5']" style="color: lightcoral" />
|
||||
<AlertTriangle :class="['inline-block', 'ml-1']" style="color: lightcoral" />
|
||||
</TooltipWrapper>
|
||||
<Lock v-if="strict" style="display: inline-block; margin-left: 5px" />
|
||||
<Lock class="ml-1.5" v-if="strict" style="display: inline-block" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useGroupStore, useInstanceStore, useLaunchStore } from '../stores';
|
||||
import { showGroupDialog } from '../coordinators/groupCoordinator';
|
||||
import { getGroupName, parseLocation } from '../shared/utils';
|
||||
import { accessTypeLocaleKeyMap } from '../shared/constants';
|
||||
|
||||
@@ -50,6 +51,9 @@
|
||||
const groupName = ref('');
|
||||
const isClosed = ref(false);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function parse() {
|
||||
const locObj = props.locationobject;
|
||||
location.value = locObj.tag;
|
||||
@@ -94,6 +98,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param accessTypeNameRaw
|
||||
*/
|
||||
function translateAccessType(accessTypeNameRaw) {
|
||||
const key = accessTypeLocaleKeyMap[accessTypeNameRaw];
|
||||
if (!key) {
|
||||
@@ -118,20 +126,20 @@
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function showLaunchDialog() {
|
||||
launchStore.showLaunchDialog(location.value, shortName.value);
|
||||
}
|
||||
|
||||
function showGroupDialog() {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function openLocationGroupDialog() {
|
||||
if (!location.value) return;
|
||||
const L = parseLocation(location.value);
|
||||
if (!L.groupId) return;
|
||||
groupStore.showGroupDialog(L.groupId);
|
||||
showGroupDialog(L.groupId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,790 +0,0 @@
|
||||
<template>
|
||||
<Sidebar side="left" variant="sidebar" collapsible="icon">
|
||||
<SidebarContent class="pt-2">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu v-if="navLayoutReady">
|
||||
<template v-for="item in menuItems" :key="item.index">
|
||||
<SidebarMenuItem v-if="!item.children?.length">
|
||||
<SidebarMenuButton
|
||||
:is-active="activeMenuIndex === item.index"
|
||||
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')"
|
||||
:class="isNavItemNotified(item) ? 'notify' : undefined"
|
||||
@click="handleMenuItemClick(item)">
|
||||
<i
|
||||
:class="item.icon"
|
||||
class="inline-flex size-6 items-center justify-center text-lg" />
|
||||
<span v-show="!isCollapsed">{{
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
}}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem v-else>
|
||||
<DropdownMenu
|
||||
v-if="isCollapsed"
|
||||
:open="collapsedDropdownOpenId === item.index"
|
||||
@update:open="(value) => handleCollapsedDropdownOpenChange(item.index, value)">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
|
||||
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')"
|
||||
:class="isNavItemNotified(item) ? 'notify' : undefined">
|
||||
<i
|
||||
:class="item.icon"
|
||||
class="inline-flex size-6 items-center justify-center text-lg" />
|
||||
<span v-show="!isCollapsed">{{
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
}}</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" class="w-56">
|
||||
<DropdownMenuItem
|
||||
v-for="entry in item.children"
|
||||
:key="entry.index"
|
||||
@select="(event) => handleCollapsedSubmenuSelect(event, entry, item.index)">
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="entry.icon"
|
||||
class="inline-flex size-4 items-center justify-center text-base" />
|
||||
<span>{{ t(entry.label) }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Collapsible
|
||||
v-else
|
||||
class="group/collapsible"
|
||||
:default-open="
|
||||
activeMenuIndex && item.children?.some((e) => e.index === activeMenuIndex)
|
||||
">
|
||||
<template #default="{ open }">
|
||||
<CollapsibleTrigger as-child>
|
||||
<SidebarMenuButton
|
||||
:is-active="item.children?.some((e) => e.index === activeMenuIndex)"
|
||||
:tooltip="item.titleIsCustom ? item.title : t(item.title || '')"
|
||||
:class="isNavItemNotified(item) ? 'notify' : undefined">
|
||||
<i
|
||||
:class="item.icon"
|
||||
class="inline-flex size-6 items-center justify-center text-lg" />
|
||||
<span v-show="!isCollapsed">{{
|
||||
item.titleIsCustom ? item.title : t(item.title || '')
|
||||
}}</span>
|
||||
<ChevronRight
|
||||
v-show="!isCollapsed"
|
||||
class="ml-auto transition-transform"
|
||||
:class="open ? 'rotate-90' : ''" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem v-for="entry in item.children" :key="entry.index">
|
||||
<SidebarMenuSubButton
|
||||
:is-active="activeMenuIndex === entry.index"
|
||||
@click="handleSubmenuClick(entry, item.index)">
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="entry.icon"
|
||||
class="inline-flex size-5 items-center justify-center text-base" />
|
||||
<span>{{ t(entry.label) }}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
</template>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter class="px-2 py-3">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="t('nav_tooltip.help_support')">
|
||||
<i class="ri-question-line inline-flex size-6 items-center justify-center text-lg" />
|
||||
<span v-show="!isCollapsed">{{ t('nav_tooltip.help_support') }}</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" class="w-56">
|
||||
<DropdownMenuItem @click="showChangeLogDialog">
|
||||
<span>{{ t('nav_menu.whats_new') }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{{ t('nav_menu.resources') }}</DropdownMenuLabel>
|
||||
<DropdownMenuItem @click="handleSupportLink('wiki')">
|
||||
<span>{{ t('nav_menu.wiki') }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{{ t('nav_menu.get_help') }}</DropdownMenuLabel>
|
||||
<DropdownMenuItem @click="handleSupportLink('github')">
|
||||
<span>{{ t('nav_menu.github') }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleSupportLink('discord')">
|
||||
<span>{{ t('nav_menu.discord') }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<SidebarMenuButton :tooltip="t('nav_tooltip.manage')">
|
||||
<span class="relative inline-flex size-6 items-center justify-center">
|
||||
<i class="ri-settings-3-line text-lg" />
|
||||
<span
|
||||
v-if="pendingVRCXUpdate || pendingVRCXInstall"
|
||||
class="absolute top-0.5 -right-1 h-1.5 w-1.5 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
<span v-show="!isCollapsed">{{ t('nav_tooltip.manage') }}</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start" class="w-54">
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<img class="h-6 w-6 cursor-pointer" :src="vrcxLogo" alt="VRCX" @click="openGithub" />
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="text-left text-sm font-medium truncate flex items-center gap-1"
|
||||
@click="openGithub">
|
||||
VRCX
|
||||
<Heart class="text-primary fill-current stroke-none" />
|
||||
</button>
|
||||
<span class="text-xs text-muted-foreground">{{ version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
v-if="pendingVRCXUpdate || pendingVRCXInstall"
|
||||
@click="showVRCXUpdateDialog">
|
||||
<span>{{ t('nav_menu.update_available') }}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="pendingVRCXUpdate || pendingVRCXInstall" />
|
||||
<DropdownMenuItem @click="handleSettingsClick">
|
||||
<span>{{ t('nav_tooltip.settings') }}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span>{{ t('view.settings.appearance.appearance.theme_mode') }}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right" align="start" class="w-54">
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="theme in themes"
|
||||
:key="theme"
|
||||
:model-value="themeMode === theme"
|
||||
indicator-position="right"
|
||||
@select="handleThemeSelect(theme)">
|
||||
<span>{{ themeDisplayName(theme) }}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span>{{ t('view.settings.appearance.theme_color.header') }}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
side="right"
|
||||
align="start"
|
||||
class="w-54 max-h-80 overflow-auto">
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="theme in themeColors"
|
||||
:key="theme.key"
|
||||
:model-value="currentThemeColor === theme.key"
|
||||
:disabled="isApplyingThemeColor"
|
||||
indicator-position="right"
|
||||
@select="handleThemeColorSelect(theme)">
|
||||
<span class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
:style="{ backgroundColor: theme.swatch }" />
|
||||
<span class="truncate">{{ theme.label }}</span>
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span>{{ t('view.settings.appearance.appearance.table_density') }}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right" align="start" class="w-54">
|
||||
<DropdownMenuCheckboxItem
|
||||
:model-value="tableDensity === 'standard'"
|
||||
indicator-position="right"
|
||||
@select="handleTableDensitySelect('standard')">
|
||||
<span>{{
|
||||
t('view.settings.appearance.appearance.table_density_standard')
|
||||
}}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
:model-value="tableDensity === 'comfortable'"
|
||||
indicator-position="right"
|
||||
@select="handleTableDensitySelect('comfortable')">
|
||||
<span>{{
|
||||
t('view.settings.appearance.appearance.table_density_comfortable')
|
||||
}}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
:model-value="tableDensity === 'compact'"
|
||||
indicator-position="right"
|
||||
@select="handleTableDensitySelect('compact')">
|
||||
<span>{{
|
||||
t('view.settings.appearance.appearance.table_density_compact')
|
||||
}}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem @click="handleOpenCustomNavDialog">
|
||||
<span>{{ t('nav_menu.custom_nav.header') }}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" @click="handleLogoutClick">
|
||||
<span>{{ t('dialog.user.actions.logout') }}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton :tooltip="t('nav_tooltip.toggle_theme')" @click="handleThemeToggle">
|
||||
<i
|
||||
:class="isDarkMode ? 'ri-moon-line' : 'ri-sun-line'"
|
||||
class="inline-flex size-6 items-center justify-center text-[19px]" />
|
||||
<span v-show="!isCollapsed">{{ t('nav_tooltip.toggle_theme') }}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
:tooltip="isCollapsed ? t('nav_tooltip.expand_menu') : t('nav_tooltip.collapse_menu')"
|
||||
@click="toggleNavCollapse">
|
||||
<i class="ri-side-bar-line inline-flex size-6 items-center justify-center text-[19px]" />
|
||||
<span v-show="!isCollapsed">{{ t('nav_tooltip.collapse_menu') }}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<CustomNavDialog
|
||||
v-model:visible="customNavDialogVisible"
|
||||
:layout="navLayout"
|
||||
@save="handleCustomNavSave"
|
||||
@reset="handleCustomNavReset" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ChevronRight, Heart } from 'lucide-vue-next';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useThemeColor } from '@/shared/utils/base/ui';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {
|
||||
useAppearanceSettingsStore,
|
||||
useAuthStore,
|
||||
useModalStore,
|
||||
useSearchStore,
|
||||
useUiStore,
|
||||
useVRCXUpdaterStore
|
||||
} from '../stores';
|
||||
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
|
||||
import { openExternalLink } from '../shared/utils';
|
||||
|
||||
import configRepository from '../service/config';
|
||||
|
||||
const CustomNavDialog = defineAsyncComponent(() => import('./dialogs/CustomNavDialog.vue'));
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const modalStore = useModalStore();
|
||||
|
||||
const createDefaultNavLayout = () => [
|
||||
{ type: 'item', key: 'feed' },
|
||||
{ type: 'item', key: 'friends-locations' },
|
||||
{ type: 'item', key: 'game-log' },
|
||||
{ type: 'item', key: 'player-list' },
|
||||
{ type: 'item', key: 'search' },
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'default-folder-favorites',
|
||||
nameKey: 'nav_tooltip.favorites',
|
||||
name: t('nav_tooltip.favorites'),
|
||||
icon: 'ri-star-line',
|
||||
items: ['favorite-friends', 'favorite-worlds', 'favorite-avatars']
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'default-folder-social',
|
||||
nameKey: 'nav_tooltip.social',
|
||||
name: t('nav_tooltip.social'),
|
||||
icon: 'ri-group-line',
|
||||
items: ['friend-log', 'friend-list', 'moderation']
|
||||
},
|
||||
{ type: 'item', key: 'notification' },
|
||||
{ type: 'item', key: 'charts' },
|
||||
{ type: 'item', key: 'tools' },
|
||||
{ type: 'item', key: 'direct-access' }
|
||||
];
|
||||
|
||||
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
|
||||
const DEFAULT_FOLDER_ICON = 'ri-menu-fold-line';
|
||||
|
||||
const VRCXUpdaterStore = useVRCXUpdaterStore();
|
||||
const { pendingVRCXUpdate, pendingVRCXInstall, appVersion } = storeToRefs(VRCXUpdaterStore);
|
||||
const { showVRCXUpdateDialog, showChangeLogDialog } = VRCXUpdaterStore;
|
||||
const uiStore = useUiStore();
|
||||
const { notifiedMenus } = storeToRefs(uiStore);
|
||||
const { directAccessPaste } = useSearchStore();
|
||||
const { logout } = useAuthStore();
|
||||
const appearanceSettingsStore = useAppearanceSettingsStore();
|
||||
const { themeMode, tableDensity, isDarkMode, isNavCollapsed: isCollapsed } = storeToRefs(appearanceSettingsStore);
|
||||
const navLayout = ref([]);
|
||||
const navLayoutReady = ref(false);
|
||||
const collapsedDropdownOpenId = ref(null);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
navLayout.value.forEach((entry) => {
|
||||
if (entry.type === 'item') {
|
||||
const definition = navDefinitionMap.get(entry.key);
|
||||
if (!definition) {
|
||||
return;
|
||||
}
|
||||
items.push({
|
||||
...definition,
|
||||
index: definition.key,
|
||||
title: definition.tooltip || definition.labelKey,
|
||||
titleIsCustom: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.type === 'folder') {
|
||||
const folderDefinitions = (entry.items || []).map((key) => navDefinitionMap.get(key)).filter(Boolean);
|
||||
|
||||
if (folderDefinitions.length < 2) {
|
||||
folderDefinitions.forEach((definition) => {
|
||||
items.push({
|
||||
...definition,
|
||||
index: definition.key,
|
||||
titleIsCustom: false
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const folderEntries = folderDefinitions.map((definition) => ({
|
||||
label: definition.labelKey,
|
||||
routeName: definition.routeName,
|
||||
index: definition.key,
|
||||
icon: definition.icon,
|
||||
action: definition.action
|
||||
}));
|
||||
|
||||
items.push({
|
||||
index: entry.id,
|
||||
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||
title: entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'),
|
||||
titleIsCustom: true,
|
||||
children: folderEntries
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
const activeMenuIndex = computed(() => {
|
||||
const currentRoute = router.currentRoute.value;
|
||||
const currentRouteName = currentRoute?.name;
|
||||
const navKey = currentRoute?.meta?.navKey || currentRouteName;
|
||||
if (!navKey) {
|
||||
return getFirstNavRoute(navLayout.value) || 'feed';
|
||||
}
|
||||
|
||||
for (const entry of navLayout.value) {
|
||||
if (entry.type === 'item' && entry.key === navKey) {
|
||||
return entry.key;
|
||||
}
|
||||
if (entry.type === 'folder' && entry.items?.includes(navKey)) {
|
||||
return navKey;
|
||||
}
|
||||
}
|
||||
|
||||
return getFirstNavRoute(navLayout.value) || 'feed';
|
||||
});
|
||||
|
||||
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
|
||||
const vrcxLogo = new URL('../../images/VRCX.png', import.meta.url).href;
|
||||
|
||||
const themes = computed(() => Object.keys(THEME_CONFIG));
|
||||
const { themeColors, currentThemeColor, isApplyingThemeColor, applyThemeColor, initThemeColor } = useThemeColor();
|
||||
|
||||
watch(
|
||||
() => locale.value,
|
||||
() => {
|
||||
if (!navLayoutReady.value) {
|
||||
return;
|
||||
}
|
||||
navLayout.value = navLayout.value.map((entry) => {
|
||||
if (entry.type === 'folder' && entry.nameKey) {
|
||||
return {
|
||||
...entry,
|
||||
name: t(entry.nameKey)
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => isCollapsed.value,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
collapsedDropdownOpenId.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const generateFolderId = () => `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
|
||||
|
||||
const sanitizeLayout = (layout) => {
|
||||
const usedKeys = new Set();
|
||||
const normalized = [];
|
||||
|
||||
const appendItemEntry = (key, target = normalized) => {
|
||||
if (!key || usedKeys.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
target.push({ type: 'item', key });
|
||||
usedKeys.add(key);
|
||||
};
|
||||
|
||||
if (Array.isArray(layout)) {
|
||||
layout.forEach((entry) => {
|
||||
if (entry?.type === 'item') {
|
||||
appendItemEntry(entry.key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry?.type === 'folder') {
|
||||
const folderItems = [];
|
||||
(entry.items || []).forEach((key) => {
|
||||
if (!key || usedKeys.has(key) || !navDefinitionMap.has(key)) {
|
||||
return;
|
||||
}
|
||||
folderItems.push(key);
|
||||
usedKeys.add(key);
|
||||
});
|
||||
|
||||
if (folderItems.length >= 2) {
|
||||
const folderNameKey = entry.nameKey || null;
|
||||
const folderName = folderNameKey ? t(folderNameKey) : entry.name || '';
|
||||
normalized.push({
|
||||
type: 'folder',
|
||||
id: entry.id || generateFolderId(),
|
||||
name: folderName,
|
||||
nameKey: folderNameKey,
|
||||
icon: entry.icon || DEFAULT_FOLDER_ICON,
|
||||
items: folderItems
|
||||
});
|
||||
} else {
|
||||
folderItems.forEach((key) => appendItemEntry(key));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
navDefinitions.forEach((item) => {
|
||||
if (!usedKeys.has(item.key)) {
|
||||
normalized.push({ type: 'item', key: item.key });
|
||||
usedKeys.add(item.key);
|
||||
}
|
||||
});
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const themeDisplayName = (themeKey) => {
|
||||
const i18nKey = `view.settings.appearance.appearance.theme_mode_${themeKey}`;
|
||||
const translated = t(i18nKey);
|
||||
if (translated !== i18nKey) {
|
||||
return translated;
|
||||
}
|
||||
return THEME_CONFIG[themeKey]?.name ?? themeKey;
|
||||
};
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
router.push({ name: 'settings' });
|
||||
};
|
||||
|
||||
const handleLogoutClick = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const handleThemeSelect = (theme) => {
|
||||
appearanceSettingsStore.setThemeMode(theme);
|
||||
};
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
appearanceSettingsStore.toggleThemeMode();
|
||||
};
|
||||
|
||||
const handleTableDensitySelect = (density) => {
|
||||
appearanceSettingsStore.setTableDensity(density);
|
||||
};
|
||||
|
||||
const handleThemeColorSelect = async (theme) => {
|
||||
if (!theme) {
|
||||
return;
|
||||
}
|
||||
await applyThemeColor(theme.key);
|
||||
};
|
||||
|
||||
const openGithub = () => {
|
||||
openExternalLink('https://github.com/vrcx-team/VRCX');
|
||||
};
|
||||
|
||||
const customNavDialogVisible = ref(false);
|
||||
|
||||
const saveNavLayout = async (layout) => {
|
||||
try {
|
||||
await configRepository.setString(
|
||||
'VRCX_customNavMenuLayoutList',
|
||||
JSON.stringify({
|
||||
layout
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to save custom nav', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCustomNavDialog = () => {
|
||||
customNavDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleCustomNavSave = async (layout) => {
|
||||
const sanitized = sanitizeLayout(layout);
|
||||
navLayout.value = sanitized;
|
||||
await saveNavLayout(sanitized);
|
||||
customNavDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const handleCustomNavReset = () => {
|
||||
modalStore
|
||||
.confirm({
|
||||
description: t('nav_menu.custom_nav.restore_default_confirm'),
|
||||
title: t('confirm.title'),
|
||||
confirmText: t('nav_menu.custom_nav.restore_default'),
|
||||
cancelText: t('nav_menu.custom_nav.cancel')
|
||||
})
|
||||
.then(async ({ ok }) => {
|
||||
if (!ok) return;
|
||||
const defaults = sanitizeLayout(createDefaultNavLayout());
|
||||
navLayout.value = defaults;
|
||||
await saveNavLayout(defaults);
|
||||
customNavDialogVisible.value = false;
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const loadNavMenuConfig = async () => {
|
||||
let layoutData = null;
|
||||
try {
|
||||
const storedValue = await configRepository.getString('VRCX_customNavMenuLayoutList');
|
||||
if (storedValue) {
|
||||
const parsed = JSON.parse(storedValue);
|
||||
if (Array.isArray(parsed)) {
|
||||
layoutData = parsed;
|
||||
} else if (Array.isArray(parsed?.layout)) {
|
||||
layoutData = parsed.layout;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom nav', error);
|
||||
} finally {
|
||||
const fallbackLayout = layoutData?.length ? layoutData : createDefaultNavLayout();
|
||||
navLayout.value = sanitizeLayout(fallbackLayout);
|
||||
navLayoutReady.value = true;
|
||||
navigateToFirstNavEntry();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSupportLink = (id) => {
|
||||
const target = links[id];
|
||||
if (target) {
|
||||
openExternalLink(target);
|
||||
}
|
||||
};
|
||||
|
||||
const isEntryNotified = (entry) => {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const targets = [];
|
||||
if (entry.routeName) {
|
||||
targets.push(entry.routeName);
|
||||
}
|
||||
if (entry.path) {
|
||||
const lastSegment = entry.path.split('/').pop();
|
||||
if (lastSegment) {
|
||||
targets.push(lastSegment);
|
||||
}
|
||||
}
|
||||
return targets.some((key) => notifiedMenus.value.includes(key));
|
||||
};
|
||||
|
||||
const isNavItemNotified = (item) => {
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
if (notifiedMenus.value.includes(item.index)) {
|
||||
return true;
|
||||
}
|
||||
if (item.children?.length) {
|
||||
return item.children.some((entry) => isEntryNotified(entry));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const triggerNavAction = (entry, navIndex = entry?.index) => {
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.action === 'direct-access') {
|
||||
directAccessPaste();
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.routeName) {
|
||||
handleRouteChange(entry.routeName, navIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.path) {
|
||||
router.push(entry.path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRouteChange = (routeName, navIndex = routeName) => {
|
||||
if (!routeName) {
|
||||
return;
|
||||
}
|
||||
router.push({ name: routeName });
|
||||
};
|
||||
|
||||
function getFirstNavRoute(layout) {
|
||||
for (const entry of layout) {
|
||||
if (entry.type === 'item') {
|
||||
const definition = navDefinitionMap.get(entry.key);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
if (entry.type === 'folder' && entry.items?.length) {
|
||||
const definition = entry.items.map((key) => navDefinitionMap.get(key)).find((def) => def?.routeName);
|
||||
if (definition?.routeName) {
|
||||
return definition.routeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let hasNavigatedToInitialRoute = false;
|
||||
const navigateToFirstNavEntry = () => {
|
||||
if (hasNavigatedToInitialRoute) {
|
||||
return;
|
||||
}
|
||||
const firstRoute = getFirstNavRoute(navLayout.value);
|
||||
if (!firstRoute) {
|
||||
return;
|
||||
}
|
||||
hasNavigatedToInitialRoute = true;
|
||||
if (router.currentRoute.value?.name !== firstRoute) {
|
||||
router.push({ name: firstRoute }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmenuClick = (entry, index) => {
|
||||
const navIndex = index || entry?.index;
|
||||
triggerNavAction(entry, navIndex);
|
||||
};
|
||||
|
||||
const handleCollapsedDropdownOpenChange = (index, value) => {
|
||||
collapsedDropdownOpenId.value = value ? index : null;
|
||||
};
|
||||
|
||||
const handleCollapsedSubmenuSelect = (event, entry, index) => {
|
||||
if (event?.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
handleSubmenuClick(entry, index);
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item) => {
|
||||
triggerNavAction(item, item?.index);
|
||||
};
|
||||
|
||||
const toggleNavCollapse = () => {
|
||||
appearanceSettingsStore.toggleNavCollapsed();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await initThemeColor();
|
||||
await loadNavMenuConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notify::after {
|
||||
position: absolute;
|
||||
top: 45%;
|
||||
left: 8px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
content: '';
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,9 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -59,8 +62,8 @@
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="flex items-center gap-2 px-2" :disabled="disabled">
|
||||
<span class="h-4 w-4 rounded border" :style="{ backgroundColor: safeValue }" />
|
||||
<span class="font-mono text-xs opacity-80">
|
||||
<span class="h-4 w-4 rounded" :style="{ backgroundColor: safeValue }" />
|
||||
<span class="text-xs opacity-80">
|
||||
{{ displayText }}
|
||||
</span>
|
||||
|
||||
@@ -93,7 +96,9 @@
|
||||
@input="onInput" />
|
||||
|
||||
<div v-if="clearable" class="mt-3 flex justify-end">
|
||||
<Button variant="ghost" size="sm" :disabled="disabled" @click="clear"> Clear </Button>
|
||||
<Button variant="ghost" size="sm" :disabled="disabled" @click="clear">
|
||||
{{ t('view.favorite.clear') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup>
|
||||
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Globe, Image, Users } from 'lucide-vue-next';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useQuickSearchStore } from '../stores/quickSearch';
|
||||
import { useUserDisplay } from '../composables/useUserDisplay';
|
||||
|
||||
import QuickSearchSync from './QuickSearchSync.vue';
|
||||
|
||||
const { userImage } = useUserDisplay();
|
||||
const quickSearchStore = useQuickSearchStore();
|
||||
const {
|
||||
isOpen,
|
||||
query,
|
||||
friendResults,
|
||||
ownAvatarResults,
|
||||
favoriteAvatarResults,
|
||||
ownWorldResults,
|
||||
favoriteWorldResults,
|
||||
ownGroupResults,
|
||||
joinedGroupResults,
|
||||
hasResults
|
||||
} = storeToRefs(quickSearchStore);
|
||||
const { selectResult } = quickSearchStore;
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* @param item
|
||||
*/
|
||||
function handleSelect(item) {
|
||||
selectResult(item);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="overflow-hidden p-0 sm:max-w-2xl" :show-close-button="false">
|
||||
<DialogHeader class="sr-only">
|
||||
<DialogTitle>{{ t('side_panel.search_placeholder') }}</DialogTitle>
|
||||
<DialogDescription>{{ t('side_panel.search_placeholder') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Command>
|
||||
<!-- Sync filterState.search → store.query -->
|
||||
<QuickSearchSync />
|
||||
<CommandInput :placeholder="t('side_panel.search_placeholder')" />
|
||||
<CommandList class="max-h-[min(400px,50vh)] overflow-y-auto overflow-x-hidden">
|
||||
<template v-if="!query || query.length < 2">
|
||||
<CommandGroup :heading="t('side_panel.search_categories')">
|
||||
<CommandItem :value="'hint-friends'" disabled class="gap-3 opacity-70">
|
||||
<Users class="size-4" />
|
||||
<span class="flex-1">{{ t('side_panel.search_friends') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t('side_panel.search_scope_all')
|
||||
}}</span>
|
||||
</CommandItem>
|
||||
<CommandItem :value="'hint-avatars'" disabled class="gap-3 opacity-70">
|
||||
<Image class="size-4" />
|
||||
<span class="flex-1">{{ t('side_panel.search_avatars') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t('side_panel.search_scope_avatars')
|
||||
}}</span>
|
||||
</CommandItem>
|
||||
<CommandItem :value="'hint-worlds'" disabled class="gap-3 opacity-70">
|
||||
<Globe class="size-4" />
|
||||
<span class="flex-1">{{ t('side_panel.search_worlds') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t('side_panel.search_scope_worlds')
|
||||
}}</span>
|
||||
</CommandItem>
|
||||
<CommandItem :value="'hint-groups'" disabled class="gap-3 opacity-70">
|
||||
<Users class="size-4" />
|
||||
<span class="flex-1">{{ t('side_panel.search_groups') }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{
|
||||
t('side_panel.search_scope_joined')
|
||||
}}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!hasResults" class="py-6 text-center text-sm text-muted-foreground">
|
||||
{{ t('side_panel.search_no_results') }}
|
||||
</div>
|
||||
|
||||
<CommandGroup v-if="friendResults.length > 0" :heading="t('side_panel.friends')">
|
||||
<CommandItem
|
||||
v-for="item in friendResults"
|
||||
:key="item.id"
|
||||
:value="[item.name, item.memo, item.note, item.id].filter(Boolean).join(' ')"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.ref"
|
||||
:src="userImage(item.ref)"
|
||||
class="size-6 rounded-full object-cover"
|
||||
loading="lazy" />
|
||||
<Users v-else class="size-4" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="truncate" :style="{ color: item.ref?.$userColour }">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.matchedField !== 'name' && item.memo"
|
||||
class="truncate text-xs text-muted-foreground">
|
||||
Memo: {{ item.memo }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.matchedField !== 'name' && item.note"
|
||||
class="truncate text-xs text-muted-foreground">
|
||||
Note: {{ item.note }}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup v-if="ownAvatarResults.length > 0" :heading="t('side_panel.search_own_avatars')">
|
||||
<CommandItem
|
||||
v-for="item in ownAvatarResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' own ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Image v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup
|
||||
v-if="favoriteAvatarResults.length > 0"
|
||||
:heading="t('side_panel.search_fav_avatars')">
|
||||
<CommandItem
|
||||
v-for="item in favoriteAvatarResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' fav ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Image v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup v-if="ownWorldResults.length > 0" :heading="t('side_panel.search_own_worlds')">
|
||||
<CommandItem
|
||||
v-for="item in ownWorldResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' own ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Globe v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup
|
||||
v-if="favoriteWorldResults.length > 0"
|
||||
:heading="t('side_panel.search_fav_worlds')">
|
||||
<CommandItem
|
||||
v-for="item in favoriteWorldResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' fav ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Globe v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup v-if="ownGroupResults.length > 0" :heading="t('side_panel.search_own_groups')">
|
||||
<CommandItem
|
||||
v-for="item in ownGroupResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' own ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Users v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup
|
||||
v-if="joinedGroupResults.length > 0"
|
||||
:heading="t('side_panel.search_joined_groups')">
|
||||
<CommandItem
|
||||
v-for="item in joinedGroupResults"
|
||||
:key="item.id"
|
||||
:value="item.name + ' joined ' + item.id"
|
||||
class="gap-3"
|
||||
@select="handleSelect(item)">
|
||||
<img
|
||||
v-if="item.imageUrl"
|
||||
:src="item.imageUrl"
|
||||
class="size-6 rounded object-cover"
|
||||
loading="lazy" />
|
||||
<Users v-else class="size-4" />
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</template>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Scale up the entire Command UI */
|
||||
|
||||
/* Taller input wrapper */
|
||||
:deep([data-slot='command-input-wrapper']) {
|
||||
height: 3rem; /* h-12 */
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
/* Larger input text */
|
||||
:deep([data-slot='command-input']) {
|
||||
font-size: 0.9375rem; /* ~15px */
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
/* Larger search icon in input */
|
||||
:deep([data-slot='command-input-wrapper'] > .lucide-search) {
|
||||
width: 1.25rem; /* size-5 */
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Bigger list items */
|
||||
:deep([data-slot='command-item']) {
|
||||
font-size: 0.9375rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Bigger group headings */
|
||||
:deep([data-slot='command-group-heading']) {
|
||||
font-size: 0.8125rem; /* ~13px */
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script>
|
||||
export default {
|
||||
render: () => null
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* Renderless bridge component — must live inside the Command context.
|
||||
* Watches filterState.search (set by CommandInput) and syncs it
|
||||
* to the global search store's query ref.
|
||||
* Overrides the built-in Command filter so that visibility is fully
|
||||
* controlled by the Worker search results (which handle confusable
|
||||
* characters, diacritics, and locale-aware matching).
|
||||
*/
|
||||
import { nextTick, watch } from 'vue';
|
||||
import { useCommand } from '@/components/ui/command';
|
||||
|
||||
import { useQuickSearchStore } from '../stores/quickSearch';
|
||||
|
||||
const { filterState, allItems, allGroups } = useCommand();
|
||||
const quickSearchStore = useQuickSearchStore();
|
||||
|
||||
function overrideFilter() {
|
||||
for (const id of allItems.value.keys()) {
|
||||
filterState.filtered.items.set(id, 1);
|
||||
}
|
||||
filterState.filtered.count = allItems.value.size;
|
||||
for (const groupId of allGroups.value.keys()) {
|
||||
filterState.filtered.groups.add(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => filterState.search,
|
||||
async (value) => {
|
||||
quickSearchStore.setQuery(value);
|
||||
|
||||
// Override the built-in Command filter for all queries.
|
||||
// The Worker already handles confusable-character normalization
|
||||
// and locale-aware matching; the Command's built-in useFilter
|
||||
// (which uses a plain Intl.Collator) would otherwise hide
|
||||
// results that the Worker correctly matched via confusables.
|
||||
if (value) {
|
||||
await nextTick();
|
||||
overrideFilter();
|
||||
}
|
||||
|
||||
// [OLD] Only override when query < 2 chars (hint categories).
|
||||
// If above approach causes issues, revert to this:
|
||||
// if (value && value.length < 2) {
|
||||
// await nextTick();
|
||||
// for (const id of allItems.value.keys()) {
|
||||
// filterState.filtered.items.set(id, 1);
|
||||
// }
|
||||
// filterState.filtered.count = allItems.value.size;
|
||||
// for (const groupId of allGroups.value.keys()) {
|
||||
// filterState.filtered.groups.add(groupId);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,717 @@
|
||||
<template>
|
||||
<div
|
||||
class="shrink-0 h-[22px] flex items-center bg-sidebar border-t border-border text-xs select-none overflow-hidden"
|
||||
style="font-family: var(--font-mono-cjk)"
|
||||
@contextmenu.prevent>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div class="flex items-center w-full h-full px-2">
|
||||
<!-- Left section -->
|
||||
<div
|
||||
class="flex items-center flex-1 min-w-0 overflow-hidden [&>*:first-child]:pl-0.5"
|
||||
style="
|
||||
mask-image: linear-gradient(to right, black calc(100% - 20px), transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 20px), transparent 100%);
|
||||
">
|
||||
<TooltipWrapper
|
||||
v-if="visibility.proxy"
|
||||
:content="
|
||||
vrcxStore.proxyServer
|
||||
? `${t('status_bar.proxy')}: ${vrcxStore.proxyServer}`
|
||||
: t('status_bar.proxy')
|
||||
"
|
||||
side="top">
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent"
|
||||
@click="handleProxyClick">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full shrink-0"
|
||||
:class="vrcxStore.proxyServer ? 'bg-status-online' : 'bg-status-offline-alt'" />
|
||||
<span class="text-foreground text-[11px]">{{
|
||||
vrcxStore.proxyServer || t('status_bar.proxy')
|
||||
}}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper
|
||||
v-if="!isMacOS && visibility.steamvr"
|
||||
:content="
|
||||
gameStore.isSteamVRRunning
|
||||
? t('status_bar.steamvr_running')
|
||||
: t('status_bar.steamvr_stopped')
|
||||
"
|
||||
side="top">
|
||||
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full shrink-0"
|
||||
:class="
|
||||
gameStore.isSteamVRRunning ? 'bg-status-online' : 'bg-status-offline-alt'
|
||||
" />
|
||||
<span class="text-foreground text-[11px]">{{ t('status_bar.steamvr') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<HoverCard
|
||||
v-if="!isMacOS && visibility.vrchat"
|
||||
v-model:open="gameHoverOpen"
|
||||
:open-delay="50"
|
||||
:close-delay="50">
|
||||
<HoverCardTrigger as-child>
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full shrink-0"
|
||||
:class="
|
||||
gameStore.isGameRunning ? 'bg-status-online' : 'bg-status-offline-alt'
|
||||
" />
|
||||
<span class="text-foreground text-[11px]">{{ t('status_bar.game') }}</span>
|
||||
<span v-if="gameStore.isGameRunning" class="text-[10px] text-foreground">{{
|
||||
gameSessionText
|
||||
}}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
v-if="gameStore.isGameRunning && userStore.currentUser.$online_for"
|
||||
class="w-auto min-w-[160px] px-3 py-2"
|
||||
side="top"
|
||||
align="start"
|
||||
:side-offset="4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[11px] text-muted-foreground">{{
|
||||
t('status_bar.game_started_at')
|
||||
}}</span>
|
||||
<span class="text-[11px] text-foreground">{{ gameStartedAtText }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[11px] text-muted-foreground">{{
|
||||
t('status_bar.game_session_duration')
|
||||
}}</span>
|
||||
<span class="text-[11px] text-foreground">{{ gameSessionDetailText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
<HoverCardContent
|
||||
v-else-if="!gameStore.isGameRunning && gameStore.lastSessionDurationMs > 0"
|
||||
class="w-auto min-w-[160px] px-3 py-2"
|
||||
side="top"
|
||||
align="start"
|
||||
:side-offset="4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[11px] text-muted-foreground">{{
|
||||
t('status_bar.game_last_session')
|
||||
}}</span>
|
||||
<span class="text-[11px] text-foreground">{{ lastSessionText }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-[11px] text-muted-foreground">{{
|
||||
t('status_bar.game_last_offline')
|
||||
}}</span>
|
||||
<span class="text-[11px] text-foreground">{{ lastOfflineTimeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<HoverCard v-if="visibility.servers" v-model:open="serversHoverOpen">
|
||||
<HoverCardTrigger as-child>
|
||||
<TooltipWrapper
|
||||
v-if="!vrcStatusStore.hasIssue"
|
||||
:content="t('status_bar.servers_ok')"
|
||||
side="top">
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent"
|
||||
@click="vrcStatusStore.openStatusPage()">
|
||||
<span class="inline-block size-2 rounded-full shrink-0 bg-status-online" />
|
||||
<span class="text-foreground text-[11px]">{{ t('status_bar.servers') }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent"
|
||||
@click="vrcStatusStore.openStatusPage()">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full shrink-0"
|
||||
:class="vrcStatusStore.isMajor ? 'bg-destructive' : 'bg-status-askme'" />
|
||||
<span class="text-foreground text-[11px]">{{ t('status_bar.servers') }}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
v-if="vrcStatusStore.hasIssue"
|
||||
class="w-[280px] px-3 py-2.5"
|
||||
side="top"
|
||||
align="start"
|
||||
:side-offset="4">
|
||||
<div class="flex items-center gap-1.5 mb-1.5">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full shrink-0"
|
||||
:class="vrcStatusStore.isMajor ? 'bg-destructive' : 'bg-status-askme'" />
|
||||
<span class="font-semibold text-xs text-foreground">{{
|
||||
t('status_bar.servers_issue')
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground m-0 leading-[1.4]">
|
||||
{{ vrcStatusStore.statusText }}
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<TooltipWrapper v-if="visibility.ws" :content="wsTooltip" side="top">
|
||||
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||
<span
|
||||
class="inline-block size-2 rounded-full shrink-0"
|
||||
:class="wsState.connected ? 'bg-status-online' : 'bg-status-offline-alt'" />
|
||||
<span class="text-foreground text-[11px]">WebSocket</span>
|
||||
<canvas ref="wsCanvasRef" class="shrink-0 rounded-sm" />
|
||||
<span class="text-[10px] text-foreground">{{
|
||||
t('status_bar.ws_avg_per_minute', { count: msgsPerMinuteAvg })
|
||||
}}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
|
||||
<!-- Right section -->
|
||||
<div class="flex items-center shrink-0 ml-auto [&>*:last-child]:border-r-0 [&>*:last-child]:pr-0.5">
|
||||
<template v-if="visibility.clocks">
|
||||
<Popover
|
||||
v-for="(clock, idx) in visibleClocks"
|
||||
:key="idx"
|
||||
v-model:open="clockPopoverOpen[idx]">
|
||||
<PopoverTrigger as-child>
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent">
|
||||
<span class="text-[10px] text-foreground">{{ formatClock(clock) }}</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[280px]" side="top" align="center">
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<label class="text-xs font-medium">{{ t('status_bar.timezone') }}</label>
|
||||
<Select
|
||||
:model-value="String(clock.offset)"
|
||||
@update:modelValue="(offset) => updateClockTimezone(idx, offset)">
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue :placeholder="t('status_bar.timezone')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-h-60">
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="opt in timezoneOptions"
|
||||
:key="opt.value"
|
||||
:value="String(opt.value)">
|
||||
<div class="flex w-full items-center justify-end font-mono">
|
||||
{{ opt.label }}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<TooltipWrapper
|
||||
v-if="visibility.zoom"
|
||||
:content="t('status_bar.zoom_tooltip')"
|
||||
side="top"
|
||||
:disabled="zoomEditing">
|
||||
<div
|
||||
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent"
|
||||
@click="toggleZoomEdit">
|
||||
<template v-if="zoomEditing">
|
||||
<span class="text-[10px] text-foreground">{{ t('status_bar.zoom') }}</span>
|
||||
<NumberField
|
||||
v-model="zoomLevel"
|
||||
:step="1"
|
||||
:format-options="{ maximumFractionDigits: 0 }"
|
||||
class="w-20"
|
||||
@click.stop
|
||||
@update:modelValue="setZoomLevel">
|
||||
<NumberFieldContent>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput
|
||||
ref="zoomInputRef"
|
||||
class="h-[18px] text-[11px] px-0.5 text-center"
|
||||
@blur="zoomEditing = false"
|
||||
@keydown.enter="zoomEditing = false"
|
||||
@keydown.escape="zoomEditing = false" />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldContent>
|
||||
</NumberField>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-[10px] text-foreground">{{ t('status_bar.zoom') }}</span>
|
||||
<span class="text-[10px] text-foreground">{{ zoomLevel }}%</span>
|
||||
</template>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
|
||||
<TooltipWrapper v-if="visibility.uptime" :content="t('status_bar.app_uptime')" side="top">
|
||||
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||
<span class="text-[10px] text-foreground">{{ t('status_bar.app_uptime_short') }}</span>
|
||||
<span class="text-[10px] text-foreground">{{ appUptimeText }}</span>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuCheckboxItem
|
||||
v-if="!isMacOS"
|
||||
:model-value="visibility.vrchat"
|
||||
@select.prevent
|
||||
@update:model-value="toggleVisibility('vrchat')">
|
||||
{{ t('status_bar.game') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="visibility.servers"
|
||||
@select.prevent
|
||||
@update:model-value="toggleVisibility('servers')">
|
||||
{{ t('status_bar.servers') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
v-if="!isMacOS"
|
||||
:model-value="visibility.steamvr"
|
||||
@select.prevent
|
||||
@update:model-value="toggleVisibility('steamvr')">
|
||||
{{ t('status_bar.steamvr') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="visibility.proxy"
|
||||
@select.prevent
|
||||
@update:model-value="toggleVisibility('proxy')">
|
||||
{{ t('status_bar.proxy') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="visibility.ws"
|
||||
@select.prevent
|
||||
@update:model-value="toggleVisibility('ws')">
|
||||
WebSocket
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="visibility.uptime"
|
||||
@select.prevent
|
||||
@update:model-value="toggleVisibility('uptime')">
|
||||
{{ t('status_bar.app_uptime_short') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
v-if="!isMacOS"
|
||||
:model-value="visibility.zoom"
|
||||
@select.prevent
|
||||
@update:model-value="toggleVisibility('zoom')">
|
||||
{{ t('status_bar.zoom') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>{{ t('status_bar.clocks') }}</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="clockCount === 0"
|
||||
@select.prevent
|
||||
@update:model-value="setClockCount('0')">
|
||||
{{ t('status_bar.clocks_none') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="clockCount === 1"
|
||||
@select.prevent
|
||||
@update:model-value="setClockCount('1')">
|
||||
1 {{ t('status_bar.clocks_label') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="clockCount === 2"
|
||||
@select.prevent
|
||||
@update:model-value="setClockCount('2')">
|
||||
2 {{ t('status_bar.clocks_label') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuCheckboxItem
|
||||
:model-value="clockCount === 3"
|
||||
@select.prevent
|
||||
@update:model-value="setClockCount('3')">
|
||||
3 {{ t('status_bar.clocks_label') }}
|
||||
</ContextMenuCheckboxItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldContent,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput
|
||||
} from '@/components/ui/number-field';
|
||||
import { useGameStore, useGeneralSettingsStore, useUserStore, useVrcStatusStore, useVrcxStore } from '@/stores';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||
import { timeToText } from '@/shared/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useIntervalFn, useNow } from '@vueuse/core';
|
||||
import { TooltipWrapper } from '@/components/ui/tooltip';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { wsState } from '@/services/websocket';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
|
||||
import {
|
||||
defaultVisibility,
|
||||
formatAppUptime,
|
||||
formatUtcHour,
|
||||
normalizeClock,
|
||||
normalizeUtcHour,
|
||||
parseClockOffset
|
||||
} from './statusBarUtils';
|
||||
|
||||
import configRepository from '../services/config';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isMacOS = computed(() => navigator.platform.includes('Mac'));
|
||||
|
||||
const gameStore = useGameStore();
|
||||
const userStore = useUserStore();
|
||||
const vrcxStore = useVrcxStore();
|
||||
const vrcStatusStore = useVrcStatusStore();
|
||||
const generalSettingsStore = useGeneralSettingsStore();
|
||||
|
||||
// --- Game session timer ---
|
||||
const gameHoverOpen = ref(false);
|
||||
|
||||
const gameSessionText = computed(() => {
|
||||
if (!gameStore.isGameRunning || !userStore.currentUser.$online_for) return '';
|
||||
const elapsed = now.value - userStore.currentUser.$online_for;
|
||||
return elapsed > 0 ? timeToText(elapsed) : '';
|
||||
});
|
||||
|
||||
const gameStartedAtText = computed(() => {
|
||||
if (!userStore.currentUser.$online_for) return '-';
|
||||
return dayjs(userStore.currentUser.$online_for).format('MM/DD HH:mm');
|
||||
});
|
||||
|
||||
const gameSessionDetailText = computed(() => {
|
||||
if (!gameStore.isGameRunning || !userStore.currentUser.$online_for) return '-';
|
||||
const elapsed = now.value - userStore.currentUser.$online_for;
|
||||
return elapsed > 0 ? timeToText(elapsed, true) : '-';
|
||||
});
|
||||
|
||||
const lastSessionText = computed(() => {
|
||||
if (gameStore.lastSessionDurationMs <= 0) return '-';
|
||||
return timeToText(gameStore.lastSessionDurationMs);
|
||||
});
|
||||
|
||||
const lastOfflineTimeText = computed(() => {
|
||||
if (gameStore.lastOfflineAt <= 0) return '-';
|
||||
return dayjs(gameStore.lastOfflineAt).format('MM/DD HH:mm');
|
||||
});
|
||||
|
||||
// --- Servers status HoverCard ---
|
||||
const serversHoverOpen = ref(false);
|
||||
let serversHoverTimer = null;
|
||||
|
||||
watch(
|
||||
() => vrcStatusStore.hasIssue,
|
||||
(hasIssue) => {
|
||||
if (hasIssue && visibility.servers) {
|
||||
serversHoverOpen.value = true;
|
||||
clearTimeout(serversHoverTimer);
|
||||
serversHoverTimer = setTimeout(() => {
|
||||
serversHoverOpen.value = false;
|
||||
}, 5000);
|
||||
} else {
|
||||
serversHoverOpen.value = false;
|
||||
clearTimeout(serversHoverTimer);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const VISIBILITY_KEY = 'VRCX_statusBarVisibility';
|
||||
|
||||
const visibility = reactive({ ...defaultVisibility });
|
||||
|
||||
/**
|
||||
*
|
||||
* @param key
|
||||
*/
|
||||
function toggleVisibility(key) {
|
||||
visibility[key] = !visibility[key];
|
||||
configRepository.setString(VISIBILITY_KEY, JSON.stringify(visibility));
|
||||
}
|
||||
|
||||
// --- WebSocket message rate + sparkline ---
|
||||
|
||||
const GRAPH_POINTS = 60;
|
||||
const WS_CANVAS_WIDTH = 48;
|
||||
const WS_CANVAS_HEIGHT = 12;
|
||||
const msgHistory = ref(new Array(GRAPH_POINTS).fill(0));
|
||||
const msgsLastMinute = ref(0);
|
||||
let lastMsgCount = wsState.messageCount;
|
||||
|
||||
const wsCanvasRef = ref(null);
|
||||
const now = useNow({ interval: 1000 });
|
||||
|
||||
useIntervalFn(() => {
|
||||
const delta = wsState.messageCount - lastMsgCount;
|
||||
lastMsgCount = wsState.messageCount;
|
||||
|
||||
const arr = msgHistory.value;
|
||||
arr.shift();
|
||||
arr.push(delta);
|
||||
msgHistory.value = arr;
|
||||
|
||||
// Sum of messages in the last 60 seconds
|
||||
msgsLastMinute.value = arr.reduce((a, b) => a + b, 0);
|
||||
|
||||
drawSparkline();
|
||||
}, 1000);
|
||||
|
||||
const msgsPerMinuteAvg = computed(() => Math.round(msgsLastMinute.value));
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function drawSparkline() {
|
||||
const canvas = wsCanvasRef.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.floor(WS_CANVAS_WIDTH * dpr);
|
||||
canvas.height = Math.floor(WS_CANVAS_HEIGHT * dpr);
|
||||
canvas.style.width = `${WS_CANVAS_WIDTH}px`;
|
||||
canvas.style.height = `${WS_CANVAS_HEIGHT}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = WS_CANVAS_WIDTH;
|
||||
const h = WS_CANVAS_HEIGHT;
|
||||
const data = msgHistory.value;
|
||||
|
||||
const fg = resolveCssColor('--foreground', '#cfd3dc');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const max = Math.max(...data, 1);
|
||||
const step = w / (data.length - 1);
|
||||
|
||||
// Only draw the sparkline stroke (no background, grid, or fill area)
|
||||
ctx.globalAlpha = 0.75;
|
||||
ctx.strokeStyle = fg;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = i * step;
|
||||
const y = h - (data[i] / max) * (h - 2);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param variableName
|
||||
* @param fallback
|
||||
*/
|
||||
function resolveCssColor(variableName, fallback) {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
|
||||
if (!value) return fallback;
|
||||
if (value.startsWith('#') || value.startsWith('rgb') || value.startsWith('hsl') || value.startsWith('oklch')) {
|
||||
return value;
|
||||
}
|
||||
return `hsl(${value})`;
|
||||
}
|
||||
|
||||
const wsTooltip = computed(() => {
|
||||
const state = wsState.connected ? t('status_bar.ws_connected') : t('status_bar.ws_disconnected');
|
||||
return `WebSocket: ${state}`;
|
||||
});
|
||||
|
||||
const appUptimeText = computed(() => {
|
||||
const elapsedSeconds = Math.floor((now.value - vrcxStore.appStartAt) / 1000);
|
||||
return formatAppUptime(elapsedSeconds);
|
||||
});
|
||||
|
||||
const CLOCKS_KEY = 'VRCX_statusBarClocks';
|
||||
const CLOCK_COUNT_KEY = 'VRCX_statusBarClockCount';
|
||||
const localOffset = normalizeUtcHour(dayjs().utcOffset() / 60);
|
||||
const defaultClocks = [{ offset: localOffset }, { offset: 0 }, { offset: localOffset < 0 ? 9 : -5 }];
|
||||
|
||||
const clocks = ref(defaultClocks.map((c) => ({ ...c })));
|
||||
const clockCount = ref(2);
|
||||
const clockPopoverOpen = reactive([false, false, false]);
|
||||
|
||||
const visibleClocks = computed(() => clocks.value.slice(0, clockCount.value));
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function saveClocks() {
|
||||
configRepository.setString(CLOCKS_KEY, JSON.stringify(clocks.value));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param val
|
||||
*/
|
||||
function setClockCount(val) {
|
||||
clockCount.value = Number(val);
|
||||
configRepository.setString(CLOCK_COUNT_KEY, String(clockCount.value));
|
||||
if (clockCount.value > 0) {
|
||||
visibility.clocks = true;
|
||||
configRepository.setString(VISIBILITY_KEY, JSON.stringify(visibility));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param clock
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatClock(clock) {
|
||||
try {
|
||||
const current = dayjs(now.value).utcOffset(normalizeUtcHour(clock.offset) * 60);
|
||||
const time = current.format('HH:mm');
|
||||
return `${time} ${formatUtcHour(clock.offset)}`;
|
||||
} catch {
|
||||
return '??:?? UTC+0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param idx
|
||||
* @param offsetValue
|
||||
*/
|
||||
function updateClockTimezone(idx, offsetValue) {
|
||||
clocks.value[idx].offset = parseClockOffset(offsetValue);
|
||||
saveClocks();
|
||||
clockPopoverOpen[idx] = false;
|
||||
}
|
||||
|
||||
const timezoneOptions = computed(() => {
|
||||
return Array.from({ length: 27 }, (_, i) => {
|
||||
const value = i - 12;
|
||||
return { value, label: formatUtcHour(value) };
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const [savedVis, savedClocks, savedClockCount] = await Promise.all([
|
||||
configRepository.getString(VISIBILITY_KEY, null),
|
||||
configRepository.getString(CLOCKS_KEY, null),
|
||||
configRepository.getString(CLOCK_COUNT_KEY, null)
|
||||
]);
|
||||
if (savedVis) {
|
||||
try {
|
||||
Object.assign(visibility, JSON.parse(savedVis));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (savedClocks) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedClocks);
|
||||
if (Array.isArray(parsed) && parsed.length === 3) {
|
||||
clocks.value = parsed.map(normalizeClock);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (savedClockCount !== null) {
|
||||
const n = Number(savedClockCount);
|
||||
if (n >= 0 && n <= 3) clockCount.value = n;
|
||||
}
|
||||
|
||||
drawSparkline();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(serversHoverTimer);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => visibility.ws,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
nextTick(() => {
|
||||
drawSparkline();
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const zoomLevel = ref(100);
|
||||
const zoomEditing = ref(false);
|
||||
const zoomInputRef = ref(null);
|
||||
|
||||
if (!isMacOS.value) {
|
||||
initZoom();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function initZoom() {
|
||||
try {
|
||||
zoomLevel.value = ((await AppApi.GetZoom()) + 10) * 10;
|
||||
} catch {
|
||||
// AppApi not available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function setZoomLevel() {
|
||||
try {
|
||||
AppApi.SetZoom(zoomLevel.value / 10 - 10);
|
||||
} catch {
|
||||
// AppApi not available
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function toggleZoomEdit() {
|
||||
if (zoomEditing.value) {
|
||||
zoomEditing.value = false;
|
||||
return;
|
||||
}
|
||||
await initZoom();
|
||||
zoomEditing.value = true;
|
||||
await nextTick();
|
||||
zoomInputRef.value?.$el?.focus?.();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function handleProxyClick() {
|
||||
generalSettingsStore.promptProxySettings();
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,8 @@
|
||||
<span>{{ text }}</span>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useNow } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { timeToText } from '../shared/utils';
|
||||
|
||||
@@ -13,18 +14,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
const now = ref(Date.now());
|
||||
const now = useNow({ interval: 15000 });
|
||||
const text = computed(() => {
|
||||
return props.epoch ? timeToText(now.value - props.epoch) : '-';
|
||||
});
|
||||
|
||||
let timerId = null;
|
||||
onMounted(() => {
|
||||
timerId = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, 15000);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timerId);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger as-child>
|
||||
<slot />
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem @click="handleViewDetails">
|
||||
<ExternalLink class="size-4" />
|
||||
{{ t('common.actions.view_details') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem v-if="isOnline" @click="handleRequestInvite">
|
||||
<Mail class="size-4" />
|
||||
{{ t('dialog.user.actions.request_invite') }}
|
||||
<ContextMenuShortcut v-if="showRecentRequestInvite">
|
||||
<Clock class="size-3.5 text-muted-foreground" />
|
||||
</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="isGameRunning"
|
||||
:disabled="!canInviteToMyLocation"
|
||||
@click="handleInvite">
|
||||
<MessageSquare class="size-4" />
|
||||
{{ t('dialog.user.actions.invite') }}
|
||||
<ContextMenuShortcut v-if="showRecentInvite">
|
||||
<Clock class="size-3.5 text-muted-foreground" />
|
||||
</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem :disabled="!currentUser?.isBoopingEnabled" @click="handleSendBoop">
|
||||
<MousePointer class="size-4" />
|
||||
{{ t('dialog.user.actions.send_boop') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator v-if="isOnline && hasLocation" />
|
||||
<ContextMenuItem
|
||||
v-if="isOnline && hasLocation"
|
||||
:disabled="!canJoin"
|
||||
@click="handleJoin">
|
||||
<LogIn class="size-4" />
|
||||
{{ t('dialog.user.info.launch_invite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="isOnline && hasLocation"
|
||||
:disabled="!canJoin"
|
||||
@click="handleSelfInvite">
|
||||
<Mail class="size-4" />
|
||||
{{ t('dialog.user.info.self_invite_tooltip') }}
|
||||
</ContextMenuItem>
|
||||
<slot name="append" />
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Clock, ExternalLink, LogIn, Mail, MessageSquare, MousePointer } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger
|
||||
} from './ui/context-menu';
|
||||
import { isRealInstance, parseLocation } from '../shared/utils';
|
||||
import { useGameStore, useLaunchStore, useLocationStore, useUserStore } from '../stores';
|
||||
import { instanceRequest, notificationRequest, queryRequest } from '../api';
|
||||
import { useInviteChecks } from '../composables/useInviteChecks';
|
||||
import { isActionRecent, recordRecentAction } from '../composables/useRecentActions';
|
||||
|
||||
import { showUserDialog } from '../coordinators/userCoordinator';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { showSendBoopDialog } = useUserStore();
|
||||
const launchStore = useLaunchStore();
|
||||
const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore());
|
||||
const { isGameRunning } = storeToRefs(useGameStore());
|
||||
const { currentUser } = storeToRefs(useUserStore());
|
||||
const { checkCanInvite, checkCanInviteSelf } = useInviteChecks();
|
||||
|
||||
const props = defineProps({
|
||||
userId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
state: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const isOnline = computed(() => props.state === 'online');
|
||||
const hasLocation = computed(() => !!props.location && isRealInstance(props.location));
|
||||
const canInviteToMyLocation = computed(() => checkCanInvite(lastLocation.value.location));
|
||||
const canJoin = computed(() => {
|
||||
if (!props.location || !isRealInstance(props.location)) return false;
|
||||
return checkCanInviteSelf(props.location);
|
||||
});
|
||||
|
||||
const showRecentRequestInvite = computed(() => isActionRecent(props.userId, 'Request Invite'));
|
||||
const showRecentInvite = computed(() => isActionRecent(props.userId, 'Invite'));
|
||||
|
||||
function handleViewDetails() {
|
||||
showUserDialog(props.userId);
|
||||
}
|
||||
|
||||
function handleRequestInvite() {
|
||||
notificationRequest.sendRequestInvite({ platform: 'standalonewindows' }, props.userId).then(() => {
|
||||
recordRecentAction(props.userId, 'Request Invite');
|
||||
toast.success(t('message.user.request_invite_sent'));
|
||||
});
|
||||
}
|
||||
|
||||
function handleInvite() {
|
||||
let currentLocation = lastLocation.value.location;
|
||||
if (currentLocation === 'traveling') {
|
||||
currentLocation = lastLocationDestination.value;
|
||||
}
|
||||
const L = parseLocation(currentLocation);
|
||||
queryRequest.fetch('world.location', { worldId: L.worldId }).then((args) => {
|
||||
notificationRequest
|
||||
.sendInvite(
|
||||
{
|
||||
instanceId: L.tag,
|
||||
worldId: L.tag,
|
||||
worldName: args.ref.name
|
||||
},
|
||||
props.userId
|
||||
)
|
||||
.then(() => {
|
||||
recordRecentAction(props.userId, 'Invite');
|
||||
toast.success(t('message.invite.sent'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleSendBoop() {
|
||||
showSendBoopDialog(props.userId);
|
||||
}
|
||||
|
||||
function handleJoin() {
|
||||
if (!props.location) return;
|
||||
launchStore.showLaunchDialog(props.location);
|
||||
}
|
||||
|
||||
function handleSelfInvite() {
|
||||
if (!props.location) return;
|
||||
const L = parseLocation(props.location);
|
||||
instanceRequest
|
||||
.selfInvite({
|
||||
instanceId: L.instanceId,
|
||||
worldId: L.worldId
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t('message.invite.self_sent'));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<component :is="itemComponent" v-if="showViewDetails" @click="$emit('view-details')">
|
||||
<ExternalLink class="size-4" />
|
||||
{{ t('common.actions.view_details') }}
|
||||
</component>
|
||||
<component :is="itemComponent" v-if="showShare" @click="$emit('share')">
|
||||
<Share2 class="size-4" />
|
||||
{{ t('dialog.world.actions.share') }}
|
||||
</component>
|
||||
<component :is="separatorComponent" v-if="showPrimarySeparator" />
|
||||
<component :is="itemComponent" v-if="showNewInstance" @click="$emit('new-instance')">
|
||||
<Flag class="size-4" />
|
||||
{{ t('dialog.world.actions.new_instance') }}
|
||||
</component>
|
||||
<component :is="itemComponent" v-if="showSelfInvite" @click="$emit('self-invite')">
|
||||
<MessageSquare class="size-4" />
|
||||
{{ selfInviteLabel }}
|
||||
</component>
|
||||
<component :is="separatorComponent" v-if="showSecondarySeparator" />
|
||||
<component :is="itemComponent" v-if="showPreviousInstances" @click="$emit('show-previous-instances')">
|
||||
<LineChart class="size-4" />
|
||||
{{ t('dialog.world.actions.show_previous_instances') }}
|
||||
</component>
|
||||
<slot name="append" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ExternalLink, Flag, LineChart, MessageSquare, Share2 } from 'lucide-vue-next';
|
||||
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator
|
||||
} from './ui/context-menu';
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator
|
||||
} from './ui/dropdown-menu';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'context'
|
||||
},
|
||||
canOpenInstanceInGame: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showViewDetails: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showShare: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showNewInstance: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showSelfInvite: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showPreviousInstances: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'view-details',
|
||||
'share',
|
||||
'new-instance',
|
||||
'self-invite',
|
||||
'show-previous-instances'
|
||||
]);
|
||||
|
||||
const selfInviteLabel = computed(() =>
|
||||
props.canOpenInstanceInGame
|
||||
? t('dialog.world.actions.new_instance_and_open_ingame')
|
||||
: t('dialog.world.actions.new_instance_and_self_invite')
|
||||
);
|
||||
|
||||
const itemComponent = computed(() =>
|
||||
props.variant === 'dropdown' ? DropdownMenuItem : ContextMenuItem
|
||||
);
|
||||
|
||||
const separatorComponent = computed(() =>
|
||||
props.variant === 'dropdown'
|
||||
? DropdownMenuSeparator
|
||||
: ContextMenuSeparator
|
||||
);
|
||||
|
||||
const showPrimarySeparator = computed(
|
||||
() =>
|
||||
(props.showViewDetails || props.showShare) &&
|
||||
(props.showNewInstance || props.showSelfInvite)
|
||||
);
|
||||
|
||||
const showSecondarySeparator = computed(
|
||||
() =>
|
||||
props.showPreviousInstances &&
|
||||
(props.showViewDetails ||
|
||||
props.showShare ||
|
||||
props.showNewInstance ||
|
||||
props.showSelfInvite)
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,224 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import AvatarInfo from '../AvatarInfo.vue';
|
||||
import en from '../../localization/en.json';
|
||||
|
||||
vi.mock('../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../views/Feed/columns.jsx', () => ({
|
||||
columns: []
|
||||
}));
|
||||
vi.mock('../../plugins/router', () => ({
|
||||
router: {
|
||||
beforeEach: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||
isReady: vi.fn().mockResolvedValue(true)
|
||||
},
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||
}))
|
||||
};
|
||||
});
|
||||
vi.mock('../../plugins/interopApi', () => ({
|
||||
initInteropApi: vi.fn()
|
||||
}));
|
||||
vi.mock('../../services/database', () => ({
|
||||
database: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (prop === '__esModule') return false;
|
||||
return vi.fn().mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
)
|
||||
}));
|
||||
vi.mock('../../services/config', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
getString: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? '{}'),
|
||||
setString: vi.fn(),
|
||||
getBool: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? false),
|
||||
setBool: vi.fn(),
|
||||
getInt: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? 0),
|
||||
setInt: vi.fn(),
|
||||
getFloat: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? 0),
|
||||
setFloat: vi.fn(),
|
||||
getObject: vi.fn().mockReturnValue(null),
|
||||
setObject: vi.fn(),
|
||||
getArray: vi.fn().mockReturnValue([]),
|
||||
setArray: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../../services/jsonStorage', () => ({
|
||||
default: vi.fn()
|
||||
}));
|
||||
vi.mock('../../services/watchState', () => ({
|
||||
watchState: { isLoggedIn: false }
|
||||
}));
|
||||
|
||||
import * as avatarCoordinatorModule from '../../coordinators/avatarCoordinator';
|
||||
vi.mock('../../coordinators/avatarCoordinator', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { ...actual, showAvatarAuthorDialog: vi.fn() };
|
||||
});
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en }
|
||||
});
|
||||
|
||||
const stubs = {
|
||||
TooltipWrapper: {
|
||||
template:
|
||||
'<span class="tooltip"><slot /><slot name="content" /></span>',
|
||||
props: ['content']
|
||||
}
|
||||
};
|
||||
|
||||
function mountAvatarInfo(props = {}, storeOverrides = {}) {
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: true,
|
||||
initialState: {
|
||||
Avatar: {},
|
||||
...storeOverrides
|
||||
}
|
||||
});
|
||||
return mount(AvatarInfo, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [i18n, pinia],
|
||||
stubs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('AvatarInfo.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('avatar name display', () => {
|
||||
test('shows hintavatarname when hintownerid is provided', () => {
|
||||
const wrapper = mountAvatarInfo({
|
||||
imageurl: 'https://example.com/avatar.png',
|
||||
hintownerid: 'usr_owner_123',
|
||||
hintavatarname: 'Cool Avatar'
|
||||
});
|
||||
expect(wrapper.text()).toContain('Cool Avatar');
|
||||
});
|
||||
|
||||
test('shows empty when no imageurl', () => {
|
||||
const wrapper = mountAvatarInfo({});
|
||||
expect(wrapper.text().trim()).toBe('Unknown Avatar');
|
||||
});
|
||||
|
||||
test('does not show hintavatarname if it is not a string', () => {
|
||||
const wrapper = mountAvatarInfo({
|
||||
imageurl: 'https://example.com/avatar.png',
|
||||
hintownerid: 'usr_owner_123',
|
||||
hintavatarname: { notAString: true }
|
||||
});
|
||||
// avatarName stays empty since hintavatarname is not a string
|
||||
expect(wrapper.text()).not.toContain('notAString');
|
||||
});
|
||||
});
|
||||
|
||||
describe('avatar type (own vs public)', () => {
|
||||
test('shows lock icon when owner matches userid (own avatar)', () => {
|
||||
const wrapper = mountAvatarInfo({
|
||||
imageurl: 'https://example.com/avatar.png',
|
||||
userid: 'usr_owner_123',
|
||||
hintownerid: 'usr_owner_123',
|
||||
hintavatarname: 'My Avatar'
|
||||
});
|
||||
expect(wrapper.find('.lucide-lock').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not show lock when owner differs from userid (public)', () => {
|
||||
const wrapper = mountAvatarInfo({
|
||||
imageurl: 'https://example.com/avatar.png',
|
||||
userid: 'usr_viewer_456',
|
||||
hintownerid: 'usr_owner_123',
|
||||
hintavatarname: 'Someone Avatar'
|
||||
});
|
||||
expect(wrapper.find('.lucide-lock').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('does not show lock when userid is undefined', () => {
|
||||
const wrapper = mountAvatarInfo({
|
||||
imageurl: 'https://example.com/avatar.png',
|
||||
hintownerid: 'usr_owner_123',
|
||||
hintavatarname: 'Avatar'
|
||||
});
|
||||
expect(wrapper.find('.lucide-lock').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avatar tags', () => {
|
||||
test('displays tags with content_ prefix stripped', () => {
|
||||
const wrapper = mountAvatarInfo({
|
||||
imageurl: 'https://example.com/avatar.png',
|
||||
hintownerid: 'usr_123',
|
||||
hintavatarname: 'Test',
|
||||
avatartags: [
|
||||
'content_horror',
|
||||
'content_gore',
|
||||
'content_adult_language'
|
||||
]
|
||||
});
|
||||
expect(wrapper.text()).toContain('horror');
|
||||
expect(wrapper.text()).toContain('gore');
|
||||
expect(wrapper.text()).toContain('adult_language');
|
||||
expect(wrapper.text()).not.toContain('content_horror');
|
||||
});
|
||||
|
||||
test('does not show tags section when avatartags is empty', () => {
|
||||
const wrapper = mountAvatarInfo({
|
||||
imageurl: 'https://example.com/avatar.png',
|
||||
hintownerid: 'usr_123',
|
||||
hintavatarname: 'Test'
|
||||
});
|
||||
expect(wrapper.find('.tooltip').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('click behavior', () => {
|
||||
test('does not call showAvatarAuthorDialog when no imageurl', async () => {
|
||||
const wrapper = mountAvatarInfo({});
|
||||
await wrapper.trigger('click');
|
||||
expect(
|
||||
avatarCoordinatorModule.showAvatarAuthorDialog
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button data-testid="back-btn" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
ArrowUp: { template: '<i />' }
|
||||
}));
|
||||
|
||||
import BackToTop from '../BackToTop.vue';
|
||||
|
||||
function setScrollY(value) {
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
configurable: true,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
describe('BackToTop.vue', () => {
|
||||
beforeEach(() => {
|
||||
setScrollY(0);
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows button after scroll threshold and scrolls window to top', async () => {
|
||||
const wrapper = mount(BackToTop, {
|
||||
props: {
|
||||
visibilityHeight: 100,
|
||||
teleport: false,
|
||||
tooltip: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="back-btn"]').exists()).toBe(false);
|
||||
|
||||
setScrollY(120);
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
|
||||
const btn = wrapper.find('[data-testid="back-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
|
||||
await btn.trigger('click');
|
||||
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
|
||||
it('uses virtualizer scrollToIndex when provided', async () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
const wrapper = mount(BackToTop, {
|
||||
props: {
|
||||
visibilityHeight: 0,
|
||||
teleport: false,
|
||||
tooltip: false,
|
||||
virtualizer: { scrollToIndex }
|
||||
}
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
|
||||
const btn = wrapper.get('[data-testid="back-btn"]');
|
||||
await btn.trigger('click');
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledWith(0, {
|
||||
align: 'start',
|
||||
behavior: 'auto'
|
||||
});
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls target element to top with auto behavior', async () => {
|
||||
const target = document.createElement('div');
|
||||
target.scrollTop = 200;
|
||||
target.scrollTo = vi.fn();
|
||||
|
||||
const wrapper = mount(BackToTop, {
|
||||
props: {
|
||||
target,
|
||||
behavior: 'auto',
|
||||
visibilityHeight: 100,
|
||||
teleport: false,
|
||||
tooltip: false
|
||||
}
|
||||
});
|
||||
|
||||
target.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
|
||||
const btn = wrapper.get('[data-testid="back-btn"]');
|
||||
await btn.trigger('click');
|
||||
|
||||
expect(target.scrollTo).toHaveBeenCalledWith({
|
||||
top: 0,
|
||||
behavior: 'auto'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setInterval: vi.fn(() => 42),
|
||||
clearInterval: vi.fn(),
|
||||
timeToText: vi.fn((ms) => `${Math.floor(ms / 1000)}s`)
|
||||
}));
|
||||
|
||||
vi.mock('worker-timers', () => ({
|
||||
setInterval: (...args) => mocks.setInterval(...args),
|
||||
clearInterval: (...args) => mocks.clearInterval(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
timeToText: (...args) => mocks.timeToText(...args)
|
||||
}));
|
||||
|
||||
import CountdownTimer from '../CountdownTimer.vue';
|
||||
|
||||
describe('CountdownTimer.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.setInterval.mockClear();
|
||||
mocks.clearInterval.mockClear();
|
||||
mocks.timeToText.mockClear();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(
|
||||
new Date('2026-01-01T00:00:00.000Z').getTime()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders remaining time on mount', async () => {
|
||||
const wrapper = mount(CountdownTimer, {
|
||||
props: {
|
||||
datetime: '2025-12-31T23:30:00.000Z',
|
||||
hours: 1
|
||||
}
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.timeToText).toHaveBeenCalled();
|
||||
expect(wrapper.text()).toContain('1800s');
|
||||
});
|
||||
|
||||
it('renders dash when countdown expired', async () => {
|
||||
const wrapper = mount(CountdownTimer, {
|
||||
props: {
|
||||
datetime: '2025-12-31T22:00:00.000Z',
|
||||
hours: 1
|
||||
}
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('-');
|
||||
|
||||
await wrapper.setProps({
|
||||
datetime: '2025-12-31T23:59:30.000Z',
|
||||
hours: 0
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.text()).toBe('-');
|
||||
});
|
||||
|
||||
it('clears interval on unmount', () => {
|
||||
const wrapper = mount(CountdownTimer, {
|
||||
props: {
|
||||
datetime: '2025-12-31T23:30:00.000Z',
|
||||
hours: 1
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
expect(mocks.setInterval).toHaveBeenCalled();
|
||||
expect(mocks.clearInterval).toHaveBeenCalledWith(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
MessageSquareWarning: { template: '<i data-testid="warn-icon" />' }
|
||||
}));
|
||||
|
||||
import DeprecationAlert from '../DeprecationAlert.vue';
|
||||
|
||||
describe('DeprecationAlert.vue', () => {
|
||||
it('renders relocated title and feature name', () => {
|
||||
const wrapper = mount(DeprecationAlert, {
|
||||
props: {
|
||||
featureName: 'InstanceActionBar'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
i18nT: {
|
||||
template:
|
||||
'<span data-testid="i18n-t"><slot name="feature" /></span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('common.feature_relocated.title');
|
||||
expect(wrapper.text()).toContain('InstanceActionBar');
|
||||
expect(wrapper.find('[data-testid="warn-icon"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetch: vi.fn(() =>
|
||||
Promise.resolve({ json: { displayName: 'Fetched User' } })
|
||||
),
|
||||
showUserDialog: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
queryRequest: {
|
||||
fetch: (...args) => mocks.fetch(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../coordinators/userCoordinator', () => ({
|
||||
showUserDialog: (...args) => mocks.showUserDialog(...args)
|
||||
}));
|
||||
|
||||
import DisplayName from '../DisplayName.vue';
|
||||
|
||||
async function flush() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('DisplayName.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.fetch.mockClear();
|
||||
mocks.showUserDialog.mockClear();
|
||||
});
|
||||
|
||||
it('uses hint directly and skips user query', async () => {
|
||||
const wrapper = mount(DisplayName, {
|
||||
props: {
|
||||
userid: 'usr_1',
|
||||
hint: 'Hint Name'
|
||||
}
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
expect(wrapper.text()).toBe('Hint Name');
|
||||
expect(mocks.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches and renders display name when hint is missing', async () => {
|
||||
const wrapper = mount(DisplayName, {
|
||||
props: {
|
||||
userid: 'usr_2'
|
||||
}
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
expect(mocks.fetch).toHaveBeenCalledWith('user.dialog', {
|
||||
userId: 'usr_2'
|
||||
});
|
||||
expect(wrapper.text()).toBe('Fetched User');
|
||||
});
|
||||
|
||||
it('opens user dialog when clicked', async () => {
|
||||
const wrapper = mount(DisplayName, {
|
||||
props: {
|
||||
userid: 'usr_3',
|
||||
hint: 'Clickable User'
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.trigger('click');
|
||||
|
||||
expect(mocks.showUserDialog).toHaveBeenCalledWith('usr_3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
emojiTable: [],
|
||||
getCachedEmoji: vi.fn(async () => ({
|
||||
frames: null,
|
||||
framesOverTime: null,
|
||||
loopStyle: null,
|
||||
versions: []
|
||||
})),
|
||||
extractFileId: vi.fn(() => 'file_1'),
|
||||
generateEmojiStyle: vi.fn(() => 'background: red;')
|
||||
}));
|
||||
|
||||
vi.mock('../../stores', () => ({
|
||||
useGalleryStore: () => ({
|
||||
getCachedEmoji: (...args) => mocks.getCachedEmoji(...args),
|
||||
emojiTable: mocks.emojiTable
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
extractFileId: (...args) => mocks.extractFileId(...args),
|
||||
generateEmojiStyle: (...args) => mocks.generateEmojiStyle(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../ui/avatar', () => ({
|
||||
Avatar: { template: '<div data-testid="avatar"><slot /></div>' },
|
||||
AvatarImage: {
|
||||
props: ['src'],
|
||||
template: '<img data-testid="avatar-image" :src="src" />'
|
||||
},
|
||||
AvatarFallback: {
|
||||
template: '<span data-testid="avatar-fallback"><slot /></span>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
Image: { template: '<i data-testid="image" />' }
|
||||
}));
|
||||
|
||||
import Emoji from '../Emoji.vue';
|
||||
|
||||
async function flush() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('Emoji.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.emojiTable.length = 0;
|
||||
mocks.getCachedEmoji.mockClear();
|
||||
mocks.extractFileId.mockReturnValue('file_1');
|
||||
mocks.generateEmojiStyle.mockClear();
|
||||
});
|
||||
|
||||
it('renders animated div when emoji has frames in table', async () => {
|
||||
mocks.emojiTable.push({
|
||||
id: 'file_1',
|
||||
frames: 4,
|
||||
framesOverTime: 1,
|
||||
loopStyle: 0,
|
||||
versions: []
|
||||
});
|
||||
|
||||
const wrapper = mount(Emoji, {
|
||||
props: {
|
||||
imageUrl: 'https://example.com/file_1.png',
|
||||
size: 64
|
||||
}
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
const animated = wrapper.find('.avatar');
|
||||
expect(animated.exists()).toBe(true);
|
||||
expect(mocks.generateEmojiStyle).toHaveBeenCalled();
|
||||
expect(animated.attributes('style')).toContain('background: red;');
|
||||
expect(wrapper.find('[data-testid="avatar"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to Avatar image when no frames', async () => {
|
||||
const wrapper = mount(Emoji, {
|
||||
props: {
|
||||
imageUrl: 'https://example.com/file_2.png',
|
||||
size: 48
|
||||
}
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
expect(mocks.getCachedEmoji).toHaveBeenCalledWith('file_1');
|
||||
expect(wrapper.find('[data-testid="avatar"]').exists()).toBe(true);
|
||||
expect(
|
||||
wrapper.find('[data-testid="avatar-image"]').attributes('src')
|
||||
).toBe('https://example.com/file_2.png');
|
||||
expect(wrapper.find('[data-testid="avatar-fallback"]').exists()).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('updates when imageUrl changes', async () => {
|
||||
const wrapper = mount(Emoji, {
|
||||
props: {
|
||||
imageUrl: 'https://example.com/a.png'
|
||||
}
|
||||
});
|
||||
|
||||
await flush();
|
||||
mocks.extractFileId.mockReturnValue('file_2');
|
||||
|
||||
await wrapper.setProps({ imageUrl: 'https://example.com/b.png' });
|
||||
await flush();
|
||||
|
||||
expect(mocks.getCachedEmoji).toHaveBeenCalledWith('file_1');
|
||||
expect(mocks.getCachedEmoji).toHaveBeenCalledWith('file_2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dialog: {
|
||||
value: {
|
||||
visible: true,
|
||||
imageUrl: 'https://example.com/a.png',
|
||||
fileName: 'a.png'
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
|
||||
vi.mock('@/stores/settings/general', () => ({
|
||||
useGeneralSettingsStore: () => ({
|
||||
disableGpuAcceleration: { value: false }
|
||||
})
|
||||
}));
|
||||
vi.mock('../../stores', () => ({
|
||||
useGalleryStore: () => ({
|
||||
fullscreenImageDialog: mocks.dialog,
|
||||
showFullscreenImageDialog: vi.fn()
|
||||
})
|
||||
}));
|
||||
vi.mock('@/lib/modalPortalLayers', () => ({
|
||||
acquireModalPortalLayer: () => ({
|
||||
element: 'body',
|
||||
bringToFront: vi.fn(),
|
||||
release: vi.fn()
|
||||
})
|
||||
}));
|
||||
vi.mock('@/lib/utils', () => ({ cn: (...a) => a.filter(Boolean).join(' ') }));
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
escapeTag: (s) => s,
|
||||
extractFileId: () => 'f1'
|
||||
}));
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: {
|
||||
info: vi.fn(() => 'id'),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
dismiss: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
vi.mock('reka-ui', () => ({
|
||||
DialogPortal: { template: '<div><slot /></div>' },
|
||||
DialogOverlay: { template: '<div><slot /></div>' },
|
||||
DialogContent: {
|
||||
emits: ['click'],
|
||||
template: '<div @click="$emit(\'click\')"><slot /></div>'
|
||||
}
|
||||
}));
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button :aria-label="$attrs[\'aria-label\']" @click="$emit(\'click\')"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
Copy: { template: '<i />' },
|
||||
Download: { template: '<i />' },
|
||||
RefreshCcw: { template: '<i />' },
|
||||
RotateCcw: { template: '<i />' },
|
||||
RotateCw: { template: '<i />' },
|
||||
X: { template: '<i />' },
|
||||
ZoomIn: { template: '<i />' },
|
||||
ZoomOut: { template: '<i />' }
|
||||
}));
|
||||
|
||||
import FullscreenImagePreview from '../FullscreenImagePreview.vue';
|
||||
|
||||
describe('FullscreenImagePreview.vue', () => {
|
||||
it('closes dialog when close button clicked', async () => {
|
||||
const wrapper = mount(FullscreenImagePreview);
|
||||
|
||||
await wrapper.get('button[aria-label="Close"]').trigger('click');
|
||||
|
||||
expect(mocks.dialog.value.visible).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkCanInviteSelf: vi.fn(() => true),
|
||||
parseLocation: vi.fn(() => ({
|
||||
isRealInstance: true,
|
||||
instanceId: 'inst_1',
|
||||
worldId: 'wrld_1',
|
||||
tag: 'wrld_1:inst_1'
|
||||
})),
|
||||
hasGroupPermission: vi.fn(() => false),
|
||||
formatDateFilter: vi.fn(() => 'formatted-date'),
|
||||
selfInvite: vi.fn(() => Promise.resolve({})),
|
||||
closeInstance: vi.fn(() =>
|
||||
Promise.resolve({ json: { id: 'inst_closed' } })
|
||||
),
|
||||
showUserDialog: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
applyInstance: vi.fn(),
|
||||
showLaunchDialog: vi.fn(),
|
||||
tryOpenInstanceInVrc: vi.fn(),
|
||||
modalConfirm: vi.fn(() => Promise.resolve({ ok: true })),
|
||||
instanceJoinHistory: { value: new Map() },
|
||||
canOpenInstanceInGame: false,
|
||||
isOpeningInstance: false,
|
||||
lastLocation: {
|
||||
location: 'wrld_here:111',
|
||||
playerList: new Set(['u1', 'u2'])
|
||||
},
|
||||
currentUser: { id: 'usr_me' },
|
||||
cachedGroups: new Map()
|
||||
}));
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(store).map(([key, value]) => [
|
||||
key,
|
||||
key === 'instanceJoinHistory'
|
||||
? value
|
||||
: (value?.value ?? value)
|
||||
])
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: {
|
||||
success: (...args) => mocks.toastSuccess(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../stores', () => ({
|
||||
useLocationStore: () => ({
|
||||
lastLocation: mocks.lastLocation
|
||||
}),
|
||||
useUserStore: () => ({
|
||||
currentUser: mocks.currentUser
|
||||
}),
|
||||
useGroupStore: () => ({
|
||||
cachedGroups: mocks.cachedGroups
|
||||
}),
|
||||
useInstanceStore: () => ({
|
||||
instanceJoinHistory: mocks.instanceJoinHistory,
|
||||
applyInstance: (...args) => mocks.applyInstance(...args)
|
||||
}),
|
||||
useModalStore: () => ({
|
||||
confirm: (...args) => mocks.modalConfirm(...args)
|
||||
}),
|
||||
useLaunchStore: () => ({
|
||||
isOpeningInstance: mocks.isOpeningInstance,
|
||||
showLaunchDialog: (...args) => mocks.showLaunchDialog(...args),
|
||||
tryOpenInstanceInVrc: (...args) => mocks.tryOpenInstanceInVrc(...args)
|
||||
}),
|
||||
useInviteStore: () => ({
|
||||
canOpenInstanceInGame: mocks.canOpenInstanceInGame
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../composables/useInviteChecks', () => ({
|
||||
useInviteChecks: () => ({
|
||||
checkCanInviteSelf: (...args) => mocks.checkCanInviteSelf(...args)
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
parseLocation: (...args) => mocks.parseLocation(...args),
|
||||
hasGroupPermission: (...args) => mocks.hasGroupPermission(...args),
|
||||
formatDateFilter: (...args) => mocks.formatDateFilter(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
instanceRequest: {
|
||||
selfInvite: (...args) => mocks.selfInvite(...args)
|
||||
},
|
||||
miscRequest: {
|
||||
closeInstance: (...args) => mocks.closeInstance(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../coordinators/userCoordinator', () => ({
|
||||
showUserDialog: (...args) => mocks.showUserDialog(...args)
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: {
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button data-testid="btn" @click="$emit(\'click\', $event)"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
History: { template: '<i data-testid="icon-history" />' },
|
||||
Loader2: { template: '<i data-testid="icon-loader" />' },
|
||||
LogIn: { template: '<i data-testid="icon-login" />' },
|
||||
Mail: { template: '<i data-testid="icon-mail" />' },
|
||||
MapPin: { template: '<i data-testid="icon-map" />' },
|
||||
RefreshCw: { template: '<i data-testid="icon-refresh" />' },
|
||||
UsersRound: { template: '<i data-testid="icon-users" />' }
|
||||
}));
|
||||
|
||||
import InstanceActionBar from '../InstanceActionBar.vue';
|
||||
|
||||
function mountBar(props = {}) {
|
||||
return mount(InstanceActionBar, {
|
||||
props: {
|
||||
location: 'wrld_base:111',
|
||||
launchLocation: '',
|
||||
inviteLocation: '',
|
||||
lastJoinLocation: '',
|
||||
instanceLocation: '',
|
||||
shortname: 'sn',
|
||||
instance: {
|
||||
ownerId: 'usr_me',
|
||||
capacity: 16,
|
||||
userCount: 4,
|
||||
hasCapacityForYou: true,
|
||||
platforms: { standalonewindows: 1, android: 2, ios: 0 },
|
||||
users: [{ id: 'usr_a', displayName: 'Alice' }],
|
||||
gameServerVersion: 123,
|
||||
$disabledContentSettings: []
|
||||
},
|
||||
friendcount: 2,
|
||||
currentlocation: '',
|
||||
showLaunch: true,
|
||||
showInvite: true,
|
||||
showRefresh: true,
|
||||
showHistory: true,
|
||||
showLastJoin: true,
|
||||
showInstanceInfo: true,
|
||||
refreshTooltip: 'refresh',
|
||||
historyTooltip: 'history',
|
||||
onRefresh: vi.fn(),
|
||||
onHistory: vi.fn(),
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: {
|
||||
props: ['content'],
|
||||
template:
|
||||
'<div><slot /><slot name="content" /><span v-if="content">{{ content }}</span></div>'
|
||||
},
|
||||
Timer: {
|
||||
props: ['epoch'],
|
||||
template: '<span data-testid="timer">{{ epoch }}</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('InstanceActionBar.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.checkCanInviteSelf.mockReturnValue(true);
|
||||
mocks.parseLocation.mockReturnValue({
|
||||
isRealInstance: true,
|
||||
instanceId: 'inst_1',
|
||||
worldId: 'wrld_1',
|
||||
tag: 'wrld_1:inst_1'
|
||||
});
|
||||
mocks.hasGroupPermission.mockReturnValue(false);
|
||||
mocks.selfInvite.mockClear();
|
||||
mocks.closeInstance.mockClear();
|
||||
mocks.showUserDialog.mockClear();
|
||||
mocks.toastSuccess.mockClear();
|
||||
mocks.applyInstance.mockClear();
|
||||
mocks.showLaunchDialog.mockClear();
|
||||
mocks.tryOpenInstanceInVrc.mockClear();
|
||||
mocks.modalConfirm.mockImplementation(() =>
|
||||
Promise.resolve({ ok: true })
|
||||
);
|
||||
mocks.instanceJoinHistory.value = new Map([
|
||||
['wrld_base:111', 1700000000]
|
||||
]);
|
||||
mocks.canOpenInstanceInGame = false;
|
||||
mocks.isOpeningInstance = false;
|
||||
mocks.lastLocation.location = 'wrld_here:111';
|
||||
mocks.lastLocation.playerList = new Set(['u1', 'u2']);
|
||||
mocks.currentUser.id = 'usr_me';
|
||||
mocks.cachedGroups = new Map();
|
||||
});
|
||||
|
||||
it('renders launch and invite buttons when invite-self is allowed', () => {
|
||||
const wrapper = mountBar({
|
||||
showRefresh: false,
|
||||
showHistory: false,
|
||||
showInstanceInfo: false
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('[data-testid="btn"]')).toHaveLength(2);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.user.info.launch_invite_tooltip'
|
||||
);
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.user.info.self_invite_tooltip'
|
||||
);
|
||||
});
|
||||
|
||||
it('launch button opens launch dialog with resolved launchLocation', async () => {
|
||||
const wrapper = mountBar({
|
||||
launchLocation: 'wrld_launch:222',
|
||||
showRefresh: false,
|
||||
showHistory: false,
|
||||
showInstanceInfo: false
|
||||
});
|
||||
const launchBtn = wrapper.findAll('[data-testid="btn"]')[0];
|
||||
expect(launchBtn).toBeTruthy();
|
||||
|
||||
await launchBtn.trigger('click');
|
||||
|
||||
expect(mocks.showLaunchDialog).toHaveBeenCalledWith('wrld_launch:222');
|
||||
});
|
||||
|
||||
it('invite button sends self-invite when canOpenInstanceInGame is false', async () => {
|
||||
const wrapper = mountBar({
|
||||
inviteLocation: 'wrld_invite:333',
|
||||
showRefresh: false,
|
||||
showHistory: false,
|
||||
showInstanceInfo: false
|
||||
});
|
||||
const inviteBtn = wrapper.findAll('[data-testid="btn"]')[1];
|
||||
expect(inviteBtn).toBeTruthy();
|
||||
|
||||
await inviteBtn.trigger('click');
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.selfInvite).toHaveBeenCalledWith({
|
||||
instanceId: 'inst_1',
|
||||
worldId: 'wrld_1',
|
||||
shortName: 'sn'
|
||||
});
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(
|
||||
'message.invite.self_sent'
|
||||
);
|
||||
});
|
||||
|
||||
it('invite button opens in VRChat when canOpenInstanceInGame is true', async () => {
|
||||
mocks.canOpenInstanceInGame = true;
|
||||
const wrapper = mountBar({
|
||||
inviteLocation: 'wrld_invite:333',
|
||||
showRefresh: false,
|
||||
showHistory: false,
|
||||
showInstanceInfo: false
|
||||
});
|
||||
const inviteBtn = wrapper.findAll('[data-testid="btn"]')[1];
|
||||
expect(inviteBtn).toBeTruthy();
|
||||
|
||||
await inviteBtn.trigger('click');
|
||||
|
||||
expect(mocks.tryOpenInstanceInVrc).toHaveBeenCalledWith(
|
||||
'wrld_1:inst_1',
|
||||
'sn'
|
||||
);
|
||||
expect(mocks.selfInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refresh/history callbacks run when buttons clicked', async () => {
|
||||
const onRefresh = vi.fn();
|
||||
const onHistory = vi.fn();
|
||||
const wrapper = mountBar({
|
||||
onRefresh,
|
||||
onHistory,
|
||||
showLaunch: false,
|
||||
showInvite: false,
|
||||
showInstanceInfo: false
|
||||
});
|
||||
const buttons = wrapper.findAll('[data-testid="btn"]');
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
await buttons[0].trigger('click');
|
||||
await buttons[1].trigger('click');
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(onHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows last-join timer and friend count', () => {
|
||||
const wrapper = mountBar({ friendcount: 5 });
|
||||
|
||||
expect(wrapper.find('[data-testid="timer"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('5');
|
||||
});
|
||||
|
||||
it('close instance flow confirms, calls api, applies instance and toasts', async () => {
|
||||
const wrapper = mountBar({
|
||||
instanceLocation: 'wrld_close:444',
|
||||
instance: {
|
||||
ownerId: 'usr_me',
|
||||
capacity: 16,
|
||||
userCount: 4,
|
||||
hasCapacityForYou: true,
|
||||
platforms: { standalonewindows: 1, android: 2, ios: 0 },
|
||||
users: [],
|
||||
gameServerVersion: 123,
|
||||
$disabledContentSettings: []
|
||||
}
|
||||
});
|
||||
|
||||
const closeBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) =>
|
||||
btn.text().includes('dialog.user.info.close_instance')
|
||||
);
|
||||
expect(closeBtn).toBeTruthy();
|
||||
|
||||
await closeBtn.trigger('click');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.modalConfirm).toHaveBeenCalled();
|
||||
expect(mocks.closeInstance).toHaveBeenCalledWith({
|
||||
location: 'wrld_close:444',
|
||||
hardClose: false
|
||||
});
|
||||
expect(mocks.applyInstance).toHaveBeenCalledWith({ id: 'inst_closed' });
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith(
|
||||
'message.instance.closed'
|
||||
);
|
||||
});
|
||||
|
||||
it('hides launch and invite buttons when invite-self is not allowed', () => {
|
||||
mocks.checkCanInviteSelf.mockReturnValue(false);
|
||||
const wrapper = mountBar({
|
||||
showRefresh: false,
|
||||
showHistory: false,
|
||||
showInstanceInfo: false
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('[data-testid="btn"]')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { getGroupName } from '../../shared/utils/group';
|
||||
import { getWorldName } from '../../shared/utils/world';
|
||||
|
||||
import Location from '../Location.vue';
|
||||
import en from '../../localization/en.json';
|
||||
|
||||
vi.mock('../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../views/Feed/columns.jsx', () => ({
|
||||
columns: []
|
||||
}));
|
||||
vi.mock('../../plugins/router', () => ({
|
||||
router: {
|
||||
beforeEach: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||
isReady: vi.fn().mockResolvedValue(true)
|
||||
},
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../plugins/interopApi', () => ({
|
||||
initInteropApi: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../services/database', () => ({
|
||||
database: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (prop === '__esModule') return false;
|
||||
return vi.fn().mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('../../services/config', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
getString: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? '{}'),
|
||||
setString: vi.fn(),
|
||||
getBool: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? false),
|
||||
setBool: vi.fn(),
|
||||
getInt: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? 0),
|
||||
setInt: vi.fn(),
|
||||
getFloat: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? 0),
|
||||
setFloat: vi.fn(),
|
||||
getObject: vi.fn().mockReturnValue(null),
|
||||
setObject: vi.fn(),
|
||||
getArray: vi.fn().mockReturnValue([]),
|
||||
setArray: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../../services/jsonStorage', () => ({
|
||||
default: vi.fn()
|
||||
}));
|
||||
vi.mock('../../services/watchState', () => ({
|
||||
watchState: { isLoggedIn: false }
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils/world', () => ({
|
||||
getWorldName: vi.fn().mockResolvedValue(''),
|
||||
isRpcWorld: vi.fn().mockReturnValue(false)
|
||||
}));
|
||||
vi.mock('../../shared/utils/group', () => ({
|
||||
getGroupName: vi.fn().mockResolvedValue(''),
|
||||
hasGroupPermission: vi.fn().mockReturnValue(false),
|
||||
hasGroupModerationPermission: vi.fn().mockReturnValue(false)
|
||||
}));
|
||||
|
||||
const mockedGetWorldName = vi.mocked(getWorldName);
|
||||
const mockedGetGroupName = vi.mocked(getGroupName);
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en }
|
||||
});
|
||||
|
||||
const stubs = {
|
||||
ContextMenu: { template: '<div><slot /></div>' },
|
||||
ContextMenuTrigger: { template: '<div><slot /></div>' },
|
||||
ContextMenuContent: { template: '<div><slot /></div>' },
|
||||
WorldActionMenuItems: { template: '<div />' },
|
||||
TooltipWrapper: {
|
||||
template: '<span><slot /></span>',
|
||||
props: [
|
||||
'content',
|
||||
'disabled',
|
||||
'delayDuration',
|
||||
'delay-duration',
|
||||
'side'
|
||||
]
|
||||
},
|
||||
Spinner: { template: '<span class="spinner" />' },
|
||||
AlertTriangle: { template: '<span class="alert-triangle" />' }
|
||||
};
|
||||
|
||||
function mountLocation(props = {}, appearanceOverrides = {}, mountOptions = {}) {
|
||||
return mount(Location, {
|
||||
props,
|
||||
...mountOptions,
|
||||
global: {
|
||||
plugins: [
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
stubActions: true,
|
||||
initialState: {
|
||||
Instance: {},
|
||||
World: {},
|
||||
Search: {},
|
||||
AppearanceSettings: {
|
||||
showInstanceIdInLocation: false,
|
||||
isAgeGatedInstancesVisible: false,
|
||||
...appearanceOverrides
|
||||
},
|
||||
Group: {}
|
||||
}
|
||||
})
|
||||
],
|
||||
stubs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
describe('Location.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('special location states', () => {
|
||||
test('shows translated text for offline location', () => {
|
||||
const wrapper = mountLocation({ location: 'offline' });
|
||||
expect(wrapper.text()).toContain('Offline');
|
||||
});
|
||||
|
||||
test('shows translated text for private location', () => {
|
||||
const wrapper = mountLocation({ location: 'private' });
|
||||
expect(wrapper.text()).toContain('Private');
|
||||
});
|
||||
|
||||
test('shows spinner and destination world when traveling', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'traveling',
|
||||
traveling: 'wrld_12345:67890~region(us)'
|
||||
});
|
||||
expect(wrapper.find('.spinner').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('wrld_12345');
|
||||
});
|
||||
|
||||
test('shows dash placeholder when location is empty', () => {
|
||||
const wrapper = mountLocation({ location: '' });
|
||||
const placeholder = wrapper.find('.text-transparent');
|
||||
expect(placeholder.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context menu attrs', () => {
|
||||
test('keeps external classes on the visible location node when context menu is enabled', () => {
|
||||
const wrapper = mountLocation(
|
||||
{ location: 'wrld_12345:67890', enableContextMenu: true },
|
||||
{},
|
||||
{
|
||||
attrs: {
|
||||
class: 'text-xs custom-location'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const locationNode = wrapper.find('.custom-location');
|
||||
expect(locationNode.exists()).toBe(true);
|
||||
expect(locationNode.classes()).toContain('text-xs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hint display', () => {
|
||||
test('shows hint with access type for valid instance', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890',
|
||||
hint: 'My World'
|
||||
});
|
||||
expect(wrapper.text()).toContain('My World');
|
||||
expect(wrapper.text()).toContain('Public');
|
||||
});
|
||||
|
||||
test('shows only hint text when no instanceId', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345',
|
||||
hint: 'Just World Name'
|
||||
});
|
||||
expect(wrapper.text()).toContain('Just World Name');
|
||||
expect(wrapper.text()).not.toContain('Public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('world ID display (no hint)', () => {
|
||||
test('shows worldId when no hint and no cached world', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890'
|
||||
});
|
||||
expect(wrapper.text()).toContain('wrld_12345');
|
||||
});
|
||||
|
||||
test('updates text after getWorldName resolves', async () => {
|
||||
mockedGetWorldName.mockResolvedValueOnce('Amazing World');
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890'
|
||||
});
|
||||
expect(wrapper.text()).toContain('wrld_12345');
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Amazing World');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('region flags', () => {
|
||||
test('shows region flag for real instances', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890~region(eu)'
|
||||
});
|
||||
expect(wrapper.find('.flags.eu').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('defaults to us region when no region specified', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890'
|
||||
});
|
||||
expect(wrapper.find('.flags.us').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not show region for offline', () => {
|
||||
const wrapper = mountLocation({ location: 'offline' });
|
||||
expect(wrapper.find('.flags').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('group name', () => {
|
||||
test('uses grouphint when provided', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890~group(grp_abc)',
|
||||
grouphint: 'My Group'
|
||||
});
|
||||
expect(wrapper.text()).toContain('My Group');
|
||||
});
|
||||
|
||||
test('calls getGroupName when no grouphint and has groupId', () => {
|
||||
mountLocation({
|
||||
location: 'wrld_12345:67890~group(grp_abc)'
|
||||
});
|
||||
expect(mockedGetGroupName).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows resolved group name after async fetch', async () => {
|
||||
mockedGetGroupName.mockResolvedValueOnce('Community Group');
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890~group(grp_abc)'
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(wrapper.text()).toContain('Community Group');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('strict and closed indicators', () => {
|
||||
test('shows lock icon for strict instances', async () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890~strict'
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('.lucide-lock').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not show lock for non-strict instances', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890'
|
||||
});
|
||||
expect(wrapper.find('.lucide-lock').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateAccessType', () => {
|
||||
test('shows Public for public instances', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890',
|
||||
hint: 'Test'
|
||||
});
|
||||
expect(wrapper.text()).toContain('Public');
|
||||
});
|
||||
|
||||
test('shows Invite for private instances', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890~private(usr_123)',
|
||||
hint: 'Test'
|
||||
});
|
||||
expect(wrapper.text()).toContain('Invite');
|
||||
});
|
||||
|
||||
test('shows Friends+ for hidden instances', () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890~hidden(usr_123)',
|
||||
hint: 'Test'
|
||||
});
|
||||
expect(wrapper.text()).toContain('Friends+');
|
||||
});
|
||||
|
||||
test('shows Group prefix for groupPublic instances', () => {
|
||||
const wrapper = mountLocation({
|
||||
location:
|
||||
'wrld_12345:67890~group(grp_123)~groupAccessType(public)',
|
||||
hint: 'Test'
|
||||
});
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Group');
|
||||
expect(text).toContain('Public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reactivity', () => {
|
||||
test('updates when location prop changes', async () => {
|
||||
const wrapper = mountLocation({ location: 'offline' });
|
||||
expect(wrapper.text()).toContain('Offline');
|
||||
await wrapper.setProps({ location: 'private' });
|
||||
expect(wrapper.text()).toContain('Private');
|
||||
});
|
||||
|
||||
test('updates when hint prop changes', async () => {
|
||||
const wrapper = mountLocation({
|
||||
location: 'wrld_12345:67890',
|
||||
hint: 'First Name'
|
||||
});
|
||||
expect(wrapper.text()).toContain('First Name');
|
||||
await wrapper.setProps({ hint: 'Second Name' });
|
||||
expect(wrapper.text()).toContain('Second Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('age-restricted display', () => {
|
||||
test('shows Restricted with lock when ageGate instance and setting is hidden', () => {
|
||||
const wrapper = mountLocation(
|
||||
{ location: 'wrld_12345:67890~ageGate', hint: 'Test World' },
|
||||
{ isAgeGatedInstancesVisible: false }
|
||||
);
|
||||
expect(wrapper.text()).toContain('Restricted');
|
||||
expect(wrapper.find('.lucide-lock').exists()).toBe(true);
|
||||
expect(wrapper.text()).not.toContain('Test World');
|
||||
});
|
||||
|
||||
test('shows normal location when ageGate instance and setting is visible', () => {
|
||||
const wrapper = mountLocation(
|
||||
{ location: 'wrld_12345:67890~ageGate', hint: 'Test World' },
|
||||
{ isAgeGatedInstancesVisible: true }
|
||||
);
|
||||
expect(wrapper.text()).toContain('Test World');
|
||||
expect(wrapper.text()).not.toContain('Restricted');
|
||||
});
|
||||
|
||||
test('shows normal location for non-ageGate instance even when setting is hidden', () => {
|
||||
const wrapper = mountLocation(
|
||||
{ location: 'wrld_12345:67890', hint: 'Normal World' },
|
||||
{ isAgeGatedInstancesVisible: false }
|
||||
);
|
||||
expect(wrapper.text()).toContain('Normal World');
|
||||
expect(wrapper.text()).not.toContain('Restricted');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cachedInstances: new Map(),
|
||||
lastInstanceApplied: { value: '' },
|
||||
showLaunchDialog: vi.fn(),
|
||||
showGroupDialog: vi.fn(),
|
||||
getGroupName: vi.fn(() => Promise.resolve('Fetched Group')),
|
||||
parseLocation: vi.fn(() => ({
|
||||
isRealInstance: true,
|
||||
tag: 'wrld_1:inst_1',
|
||||
groupId: 'grp_1'
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
storeToRefs: (store) => store
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key) => key
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../../stores', () => ({
|
||||
useInstanceStore: () => ({
|
||||
cachedInstances: mocks.cachedInstances,
|
||||
lastInstanceApplied: mocks.lastInstanceApplied
|
||||
}),
|
||||
useLaunchStore: () => ({
|
||||
showLaunchDialog: (...args) => mocks.showLaunchDialog(...args)
|
||||
}),
|
||||
useGroupStore: () => ({})
|
||||
}));
|
||||
|
||||
vi.mock('../../coordinators/groupCoordinator', () => ({
|
||||
showGroupDialog: (...args) => mocks.showGroupDialog(...args)
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/constants', () => ({
|
||||
accessTypeLocaleKeyMap: {
|
||||
friends: 'dialog.world.instance.friends',
|
||||
groupPublic: 'dialog.world.instance.group_public',
|
||||
group: 'dialog.world.instance.group'
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
getGroupName: (...args) => mocks.getGroupName(...args),
|
||||
parseLocation: (...args) => mocks.parseLocation(...args)
|
||||
}));
|
||||
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
AlertTriangle: { template: '<i data-testid="alert" />' },
|
||||
Lock: { template: '<i data-testid="lock" />' },
|
||||
Unlock: { template: '<i data-testid="unlock" />' }
|
||||
}));
|
||||
|
||||
import LocationWorld from '../LocationWorld.vue';
|
||||
|
||||
async function flush() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function mountComponent(props = {}) {
|
||||
return mount(LocationWorld, {
|
||||
props: {
|
||||
locationobject: {
|
||||
tag: 'wrld_1:inst_1',
|
||||
accessTypeName: 'friends',
|
||||
strict: false,
|
||||
shortName: 'short-1',
|
||||
userId: 'usr_owner',
|
||||
region: 'eu',
|
||||
instanceName: 'Instance Name',
|
||||
groupId: 'grp_1'
|
||||
},
|
||||
currentuserid: 'usr_owner',
|
||||
worlddialogshortname: '',
|
||||
grouphint: '',
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
TooltipWrapper: {
|
||||
props: ['content'],
|
||||
template: '<span><slot /></span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('LocationWorld.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.cachedInstances = new Map();
|
||||
mocks.lastInstanceApplied.value = '';
|
||||
mocks.showLaunchDialog.mockClear();
|
||||
mocks.showGroupDialog.mockClear();
|
||||
mocks.getGroupName.mockClear();
|
||||
mocks.parseLocation.mockClear();
|
||||
mocks.parseLocation.mockImplementation(() => ({
|
||||
isRealInstance: true,
|
||||
tag: 'wrld_1:inst_1',
|
||||
groupId: 'grp_1'
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders translated access type and instance name', () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'dialog.world.instance.friends #Instance Name'
|
||||
);
|
||||
expect(wrapper.find('.flags.eu').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('marks unlocked for owner and opens launch dialog on click', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.find('[data-testid="unlock"]').exists()).toBe(true);
|
||||
|
||||
await wrapper.findAll('.cursor-pointer')[0].trigger('click');
|
||||
|
||||
expect(mocks.showLaunchDialog).toHaveBeenCalledWith(
|
||||
'wrld_1:inst_1',
|
||||
'short-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows group hint and opens group dialog', async () => {
|
||||
const wrapper = mountComponent({ grouphint: 'Hint Group' });
|
||||
|
||||
expect(wrapper.text()).toContain('(Hint Group)');
|
||||
|
||||
await wrapper.findAll('.cursor-pointer')[1].trigger('click');
|
||||
|
||||
expect(mocks.showGroupDialog).toHaveBeenCalledWith('grp_1');
|
||||
});
|
||||
|
||||
it('loads group name asynchronously when no hint', async () => {
|
||||
const wrapper = mountComponent({ grouphint: '' });
|
||||
await flush();
|
||||
|
||||
expect(mocks.getGroupName).toHaveBeenCalledWith('grp_1');
|
||||
expect(wrapper.text()).toContain('(Fetched Group)');
|
||||
});
|
||||
|
||||
it('shows closed indicator and strict lock from instance cache', () => {
|
||||
mocks.cachedInstances = new Map([
|
||||
[
|
||||
'wrld_1:inst_1',
|
||||
{
|
||||
displayName: 'Resolved Name',
|
||||
closedAt: '2026-01-01T00:00:00.000Z'
|
||||
}
|
||||
]
|
||||
]);
|
||||
|
||||
const wrapper = mountComponent({
|
||||
locationobject: {
|
||||
tag: 'wrld_1:inst_1',
|
||||
accessTypeName: 'friends',
|
||||
strict: true,
|
||||
shortName: 'short-1',
|
||||
userId: 'usr_other',
|
||||
region: 'us',
|
||||
instanceName: 'Fallback Name',
|
||||
groupId: 'grp_1'
|
||||
},
|
||||
currentuserid: 'usr_me'
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('#Resolved Name');
|
||||
expect(wrapper.find('[data-testid="alert"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="lock"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import en from '../../localization/en.json';
|
||||
|
||||
vi.mock('../../views/Feed/Feed.vue', () => ({
|
||||
default: { template: '<div />' }
|
||||
}));
|
||||
vi.mock('../../views/Feed/columns.jsx', () => ({ columns: [] }));
|
||||
vi.mock('../../plugins/router', () => ({
|
||||
router: {
|
||||
beforeEach: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||
isReady: vi.fn().mockResolvedValue(true)
|
||||
},
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||
}))
|
||||
};
|
||||
});
|
||||
vi.mock('../../plugins/interopApi', () => ({ initInteropApi: vi.fn() }));
|
||||
vi.mock('../../services/database', () => ({
|
||||
database: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (prop === '__esModule') return false;
|
||||
return vi.fn().mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
)
|
||||
}));
|
||||
vi.mock('../../services/config', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
getString: vi.fn().mockImplementation((_k, d) => d ?? '{}'),
|
||||
setString: vi.fn(),
|
||||
getBool: vi.fn().mockImplementation((_k, d) => d ?? false),
|
||||
setBool: vi.fn(),
|
||||
getInt: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||
setInt: vi.fn(),
|
||||
getFloat: vi.fn().mockImplementation((_k, d) => d ?? 0),
|
||||
setFloat: vi.fn(),
|
||||
getObject: vi.fn().mockReturnValue(null),
|
||||
setObject: vi.fn(),
|
||||
getArray: vi.fn().mockReturnValue([]),
|
||||
setArray: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}));
|
||||
vi.mock('../../services/jsonStorage', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../services/watchState', () => ({
|
||||
watchState: { isLoggedIn: false }
|
||||
}));
|
||||
|
||||
import OtpDialogModal from '../ui/dialog/OtpDialogModal.vue';
|
||||
import { useModalStore } from '../../stores/modal';
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en }
|
||||
});
|
||||
|
||||
// Stubs: render children directly so we can inspect DOM without real reka-ui portals
|
||||
const stubs = {
|
||||
Dialog: {
|
||||
template: '<div class="dialog-stub" v-if="open"><slot /></div>',
|
||||
props: ['open']
|
||||
},
|
||||
DialogContent: {
|
||||
template: '<div class="dialog-content-stub"><slot /></div>'
|
||||
},
|
||||
DialogHeader: { template: '<div class="dialog-header"><slot /></div>' },
|
||||
DialogTitle: { template: '<span class="dialog-title"><slot /></span>' },
|
||||
DialogDescription: {
|
||||
template: '<span class="dialog-description"><slot /></span>'
|
||||
},
|
||||
DialogFooter: { template: '<div class="dialog-footer"><slot /></div>' },
|
||||
InputOTP: {
|
||||
name: 'InputOTP',
|
||||
template:
|
||||
'<div class="input-otp-stub"><input :data-maxlength="maxlength" :inputmode="inputmode" /><slot /></div>',
|
||||
props: ['maxlength', 'inputmode', 'modelValue', 'pasteTransformer'],
|
||||
emits: ['update:modelValue', 'complete']
|
||||
},
|
||||
InputOTPGroup: { template: '<div class="otp-group"><slot /></div>' },
|
||||
InputOTPSlot: {
|
||||
template: '<div class="otp-slot" :data-index="index"></div>',
|
||||
props: ['index']
|
||||
},
|
||||
InputOTPSeparator: { template: '<div class="otp-separator">-</div>' }
|
||||
};
|
||||
|
||||
function mountOtpDialog(storeOverrides = {}) {
|
||||
const pinia = createTestingPinia({
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
Modal: storeOverrides
|
||||
}
|
||||
});
|
||||
return mount(OtpDialogModal, {
|
||||
global: {
|
||||
plugins: [i18n, pinia],
|
||||
stubs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('OtpDialogModal.vue', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('visibility', () => {
|
||||
test('does not render when otpOpen is false', () => {
|
||||
const wrapper = mountOtpDialog({ otpOpen: false });
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-stub').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('renders dialog when otpOpen is true', () => {
|
||||
const wrapper = mountOtpDialog({ otpOpen: true });
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-stub').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('title and description', () => {
|
||||
test('displays title and description from store', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpTitle: 'Enter TOTP Code',
|
||||
otpDescription: 'Check your authenticator app'
|
||||
});
|
||||
store = useModalStore();
|
||||
expect(wrapper.find('.dialog-title').text()).toBe(
|
||||
'Enter TOTP Code'
|
||||
);
|
||||
expect(wrapper.find('.dialog-description').text()).toBe(
|
||||
'Check your authenticator app'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode rendering', () => {
|
||||
test('renders 6 slots for totp mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe(
|
||||
'numeric'
|
||||
);
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(6);
|
||||
expect(wrapper.find('.otp-separator').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('renders 6 slots for emailOtp mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'emailOtp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe(
|
||||
'numeric'
|
||||
);
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(6);
|
||||
});
|
||||
|
||||
test('renders 8 slots with separator for otp (recovery) mode', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'otp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.find('.input-otp-stub');
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.find('input').attributes('data-maxlength')).toBe(
|
||||
'8'
|
||||
);
|
||||
expect(otpInput.find('input').attributes('inputmode')).toBe('text');
|
||||
const slots = wrapper.findAll('.otp-slot');
|
||||
expect(slots).toHaveLength(8);
|
||||
expect(wrapper.find('.otp-separator').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('does not render otp input when mode is totp', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
// Should not have the 8-slot recovery code input
|
||||
const inputs = wrapper.findAll('.input-otp-stub');
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].find('input').attributes('data-maxlength')).toBe(
|
||||
'6'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('button text', () => {
|
||||
test('displays ok and cancel text from store', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpOkText: 'Verify',
|
||||
otpCancelText: 'Use Recovery'
|
||||
});
|
||||
store = useModalStore();
|
||||
const buttons = wrapper.findAll('button');
|
||||
const cancelBtn = buttons.find((b) => b.text() === 'Use Recovery');
|
||||
const okBtn = buttons.find((b) => b.text() === 'Verify');
|
||||
expect(cancelBtn).toBeTruthy();
|
||||
expect(okBtn).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel button', () => {
|
||||
test('calls handleOtpCancel when cancel button is clicked', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpOkText: 'Verify',
|
||||
otpCancelText: 'Cancel'
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpCancel');
|
||||
|
||||
const cancelBtn = wrapper
|
||||
.findAll('button')
|
||||
.find((b) => b.text() === 'Cancel');
|
||||
await cancelBtn.trigger('click');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
test('does not call handleOtpOk on form submit when value is empty', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpOk');
|
||||
|
||||
await wrapper.find('form').trigger('submit');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete event', () => {
|
||||
test('calls handleOtpOk when InputOTP emits complete', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const spy = vi.spyOn(store, 'handleOtpOk');
|
||||
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
otpInput.vm.$emit('complete', '123456');
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('value reset', () => {
|
||||
test('resets otpValue when dialog opens', async () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: false,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
|
||||
// Simulate opening: the watcher clears the value
|
||||
store.otpOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
// Wait for the watcher + nextTick inside it
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Dialog should now be visible and InputOTP should have empty modelValue
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
expect(otpInput.exists()).toBe(true);
|
||||
expect(otpInput.props('modelValue')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste transformer', () => {
|
||||
test('recovery code InputOTP has pasteTransformer that strips non-alphanumeric chars', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'otp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
const transformer = otpInput.props('pasteTransformer');
|
||||
expect(transformer).toBeTypeOf('function');
|
||||
expect(transformer('abcd-1234')).toBe('abcd1234');
|
||||
expect(transformer('ab-cd-12-34')).toBe('abcd1234');
|
||||
expect(transformer('abcd1234')).toBe('abcd1234');
|
||||
});
|
||||
|
||||
test('totp InputOTP does not have pasteTransformer', () => {
|
||||
const wrapper = mountOtpDialog({
|
||||
otpOpen: true,
|
||||
otpMode: 'totp'
|
||||
});
|
||||
store = useModalStore();
|
||||
const otpInput = wrapper.findComponent({ name: 'InputOTP' });
|
||||
expect(otpInput.props('pasteTransformer')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
selectResult: vi.fn(),
|
||||
userImage: vi.fn(() => 'https://example.com/u.png'),
|
||||
isOpen: { value: true },
|
||||
query: { value: '' },
|
||||
friendResults: { value: [] },
|
||||
ownAvatarResults: { value: [] },
|
||||
favoriteAvatarResults: { value: [] },
|
||||
ownWorldResults: { value: [] },
|
||||
favoriteWorldResults: { value: [] },
|
||||
ownGroupResults: { value: [] },
|
||||
joinedGroupResults: { value: [] },
|
||||
hasResults: { value: false }
|
||||
}));
|
||||
|
||||
vi.mock('pinia', async (i) => ({ ...(await i()), storeToRefs: (s) => s }));
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k) => k }) }));
|
||||
vi.mock('../../stores/quickSearch', () => ({
|
||||
useQuickSearchStore: () => ({
|
||||
isOpen: mocks.isOpen,
|
||||
query: mocks.query,
|
||||
friendResults: mocks.friendResults,
|
||||
ownAvatarResults: mocks.ownAvatarResults,
|
||||
favoriteAvatarResults: mocks.favoriteAvatarResults,
|
||||
ownWorldResults: mocks.ownWorldResults,
|
||||
favoriteWorldResults: mocks.favoriteWorldResults,
|
||||
ownGroupResults: mocks.ownGroupResults,
|
||||
joinedGroupResults: mocks.joinedGroupResults,
|
||||
hasResults: mocks.hasResults,
|
||||
selectResult: (...args) => mocks.selectResult(...args)
|
||||
})
|
||||
}));
|
||||
vi.mock('../../composables/useUserDisplay', () => ({
|
||||
useUserDisplay: () => ({ userImage: (...a) => mocks.userImage(...a) })
|
||||
}));
|
||||
vi.mock('../QuickSearchSync.vue', () => ({
|
||||
default: { template: '<div data-testid="sync" />' }
|
||||
}));
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: { template: '<div><slot /></div>' },
|
||||
DialogContent: { template: '<div><slot /></div>' },
|
||||
DialogHeader: { template: '<div><slot /></div>' },
|
||||
DialogTitle: { template: '<div><slot /></div>' },
|
||||
DialogDescription: { template: '<div><slot /></div>' }
|
||||
}));
|
||||
vi.mock('@/components/ui/command', () => ({
|
||||
Command: { template: '<div><slot /></div>' },
|
||||
CommandInput: { template: '<input />' },
|
||||
CommandList: { template: '<div><slot /></div>' },
|
||||
CommandGroup: { template: '<div><slot /></div>' },
|
||||
CommandItem: {
|
||||
emits: ['select'],
|
||||
template:
|
||||
'<button data-testid="cmd-item" @click="$emit(\'select\')"><slot /></button>'
|
||||
}
|
||||
}));
|
||||
vi.mock('lucide-vue-next', () => ({
|
||||
Globe: { template: '<i />' },
|
||||
Image: { template: '<i />' },
|
||||
Users: { template: '<i />' }
|
||||
}));
|
||||
|
||||
import QuickSearchDialog from '../QuickSearchDialog.vue';
|
||||
|
||||
describe('QuickSearchDialog.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.selectResult.mockClear();
|
||||
mocks.query.value = '';
|
||||
mocks.hasResults.value = false;
|
||||
mocks.friendResults.value = [];
|
||||
});
|
||||
|
||||
it('renders search dialog structure', () => {
|
||||
const wrapper = mount(QuickSearchDialog);
|
||||
expect(wrapper.text()).toContain('side_panel.search_placeholder');
|
||||
expect(wrapper.find('[data-testid="sync"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setQuery: vi.fn(),
|
||||
filterStateRaw: {
|
||||
search: '',
|
||||
filtered: {
|
||||
items: new Map(),
|
||||
count: 0,
|
||||
groups: new Set()
|
||||
}
|
||||
},
|
||||
filterState: null,
|
||||
allItemsEntries: [
|
||||
['a', {}],
|
||||
['b', {}]
|
||||
],
|
||||
allGroupsEntries: [['g1', {}]]
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/command', async () => {
|
||||
const { reactive, ref } = await import('vue');
|
||||
const filterState = reactive(mocks.filterStateRaw);
|
||||
mocks.filterState = filterState;
|
||||
const allItems = ref(new Map(mocks.allItemsEntries));
|
||||
const allGroups = ref(new Map(mocks.allGroupsEntries));
|
||||
return {
|
||||
useCommand: () => ({
|
||||
filterState,
|
||||
allItems,
|
||||
allGroups
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../stores/quickSearch', () => ({
|
||||
useQuickSearchStore: () => ({
|
||||
setQuery: (...args) => mocks.setQuery(...args)
|
||||
})
|
||||
}));
|
||||
|
||||
import QuickSearchSync from '../QuickSearchSync.vue';
|
||||
|
||||
describe('QuickSearchSync.vue', () => {
|
||||
it('syncs query and keeps hint groups/items visible when query length < 2', async () => {
|
||||
mount(QuickSearchSync);
|
||||
|
||||
mocks.filterState.search = 'a';
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.setQuery).toHaveBeenCalledWith('a');
|
||||
expect(mocks.filterState.filtered.count).toBe(2);
|
||||
expect(mocks.filterState.filtered.items.get('a')).toBe(1);
|
||||
expect(mocks.filterState.filtered.groups.has('g1')).toBe(true);
|
||||
});
|
||||
|
||||
it('overrides Command filter for longer queries so Worker results are not hidden', async () => {
|
||||
mount(QuickSearchSync);
|
||||
|
||||
mocks.filterState.search = 'rene';
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(mocks.setQuery).toHaveBeenCalledWith('rene');
|
||||
expect(mocks.filterState.filtered.count).toBe(2);
|
||||
expect(mocks.filterState.filtered.items.get('a')).toBe(1);
|
||||
expect(mocks.filterState.filtered.items.get('b')).toBe(1);
|
||||
expect(mocks.filterState.filtered.groups.has('g1')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
import StatusBar from '../StatusBar.vue';
|
||||
import en from '../../localization/en.json';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('../../services/config', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
getString: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? '{}'),
|
||||
setString: vi.fn(),
|
||||
getBool: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? false),
|
||||
setBool: vi.fn(),
|
||||
getInt: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? 0),
|
||||
setInt: vi.fn(),
|
||||
getFloat: vi
|
||||
.fn()
|
||||
.mockImplementation((_key, defaultValue) => defaultValue ?? 0),
|
||||
setFloat: vi.fn(),
|
||||
getObject: vi.fn().mockReturnValue(null),
|
||||
setObject: vi.fn(),
|
||||
getArray: vi.fn().mockReturnValue([]),
|
||||
setArray: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../services/websocket', () => ({
|
||||
wsState: { connected: false, messageCount: 0, bytesReceived: 0 },
|
||||
initWebsocket: vi.fn(),
|
||||
closeWebSocket: vi.fn(),
|
||||
reconnectWebSocket: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../services/webapi', () => ({
|
||||
default: {
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: JSON.stringify({
|
||||
page: { updated_at: '2026-01-01T00:00:00.000Z' },
|
||||
status: { description: 'All Systems Operational' }
|
||||
})
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('worker-timers', () => ({
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
clearTimeout: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../services/jsonStorage', () => ({
|
||||
default: vi.fn()
|
||||
}));
|
||||
vi.mock('../../services/watchState', () => ({
|
||||
watchState: { isLoggedIn: false }
|
||||
}));
|
||||
vi.mock('../../services/database', () => ({
|
||||
database: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, prop) => {
|
||||
if (prop === '__esModule') return false;
|
||||
return vi.fn().mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
)
|
||||
}));
|
||||
vi.mock('../../plugins/router', () => ({
|
||||
router: {
|
||||
beforeEach: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||
isReady: vi.fn().mockResolvedValue(true)
|
||||
},
|
||||
initRouter: vi.fn()
|
||||
}));
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||
}))
|
||||
};
|
||||
});
|
||||
vi.mock('../../plugins/interopApi', () => ({
|
||||
initInteropApi: vi.fn()
|
||||
}));
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en }
|
||||
});
|
||||
|
||||
const stubs = {
|
||||
TooltipWrapper: {
|
||||
template: '<span data-testid="tooltip"><slot /></span>',
|
||||
props: [
|
||||
'content',
|
||||
'disabled',
|
||||
'delayDuration',
|
||||
'delay-duration',
|
||||
'side'
|
||||
]
|
||||
},
|
||||
ContextMenu: { template: '<div><slot /></div>' },
|
||||
ContextMenuTrigger: { template: '<div><slot /></div>' },
|
||||
ContextMenuContent: { template: '<div><slot /></div>' },
|
||||
ContextMenuCheckboxItem: {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['modelValue']
|
||||
},
|
||||
ContextMenuSeparator: { template: '<div />' },
|
||||
ContextMenuSub: { template: '<div><slot /></div>' },
|
||||
ContextMenuSubTrigger: { template: '<div><slot /></div>' },
|
||||
ContextMenuSubContent: { template: '<div><slot /></div>' },
|
||||
ContextMenuRadioGroup: {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['modelValue']
|
||||
},
|
||||
ContextMenuRadioItem: { template: '<div><slot /></div>', props: ['value'] },
|
||||
HoverCard: {
|
||||
template: '<div data-testid="hover-card"><slot /></div>',
|
||||
props: ['open']
|
||||
},
|
||||
HoverCardTrigger: {
|
||||
template: '<div data-testid="hover-card-trigger"><slot /></div>'
|
||||
},
|
||||
HoverCardContent: {
|
||||
template: '<div data-testid="hover-card-content"><slot /></div>',
|
||||
props: ['class', 'side', 'align', 'sideOffset']
|
||||
},
|
||||
Popover: { template: '<div><slot /></div>', props: ['open'] },
|
||||
PopoverTrigger: { template: '<div><slot /></div>' },
|
||||
PopoverContent: {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['class', 'side', 'align']
|
||||
},
|
||||
Select: { template: '<div><slot /></div>', props: ['modelValue'] },
|
||||
SelectTrigger: { template: '<div><slot /></div>', props: ['size'] },
|
||||
SelectValue: { template: '<span />', props: ['placeholder'] },
|
||||
SelectContent: { template: '<div><slot /></div>', props: ['class'] },
|
||||
SelectGroup: { template: '<div><slot /></div>' },
|
||||
SelectItem: { template: '<div><slot /></div>', props: ['value'] },
|
||||
NumberField: {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['modelValue', 'step', 'formatOptions', 'class']
|
||||
},
|
||||
NumberFieldContent: { template: '<div><slot /></div>' },
|
||||
NumberFieldDecrement: { template: '<button />' },
|
||||
NumberFieldIncrement: { template: '<button />' },
|
||||
NumberFieldInput: { template: '<input />', props: ['class'] }
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param storeOverrides
|
||||
*/
|
||||
function mountStatusBar(storeOverrides = {}) {
|
||||
return mount(StatusBar, {
|
||||
global: {
|
||||
plugins: [
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
stubActions: true,
|
||||
initialState: {
|
||||
Game: {
|
||||
isGameRunning: false,
|
||||
isSteamVRRunning: false,
|
||||
lastSessionDurationMs: 0,
|
||||
lastOfflineAt: 0,
|
||||
...storeOverrides.Game
|
||||
},
|
||||
Vrcx: {
|
||||
proxyServer: '',
|
||||
appStartAt: Date.now(),
|
||||
...storeOverrides.Vrcx
|
||||
},
|
||||
VrcStatus: {
|
||||
lastStatus: '',
|
||||
lastStatusTime: null,
|
||||
lastStatusSummary: '',
|
||||
...storeOverrides.VrcStatus
|
||||
},
|
||||
User: {
|
||||
currentUser: {
|
||||
$online_for: Date.now()
|
||||
},
|
||||
...storeOverrides.User
|
||||
},
|
||||
GeneralSettings: {
|
||||
...storeOverrides.GeneralSettings
|
||||
}
|
||||
}
|
||||
})
|
||||
],
|
||||
stubs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('StatusBar.vue - Servers indicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('shows "Game" label instead of "VRChat" for game running indicator', () => {
|
||||
const wrapper = mountStatusBar({ Game: { isGameRunning: true } });
|
||||
expect(wrapper.text()).toContain('Game');
|
||||
});
|
||||
|
||||
test('shows Servers indicator with green dot when no issues', () => {
|
||||
const wrapper = mountStatusBar();
|
||||
expect(wrapper.text()).toContain('Servers');
|
||||
const serversDots = wrapper.findAll('.bg-status-online');
|
||||
expect(serversDots.length).toBeGreaterThan(0);
|
||||
expect(wrapper.find('.bg-status-askme').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('shows Servers indicator with yellow dot when there is an issue', () => {
|
||||
const wrapper = mountStatusBar({
|
||||
VrcStatus: {
|
||||
lastStatus: 'Partial System Outage'
|
||||
}
|
||||
});
|
||||
expect(wrapper.text()).toContain('Servers');
|
||||
expect(wrapper.find('.bg-status-askme').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('shows HoverCard content with status text when there is an issue', () => {
|
||||
const wrapper = mountStatusBar({
|
||||
VrcStatus: {
|
||||
lastStatus: 'Partial System Outage',
|
||||
lastStatusSummary: 'API, CDN'
|
||||
}
|
||||
});
|
||||
const hoverContent = wrapper.find('[data-testid="hover-card-content"]');
|
||||
expect(hoverContent.exists()).toBe(true);
|
||||
expect(hoverContent.text()).toContain('VRChat Server Issues');
|
||||
});
|
||||
|
||||
test('does not show HoverCard content when no issues', () => {
|
||||
const wrapper = mountStatusBar();
|
||||
const hoverContent = wrapper.find('[data-testid="hover-card-content"]');
|
||||
expect(hoverContent.exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('shows Servers indicator in context menu', () => {
|
||||
const wrapper = mountStatusBar();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Servers');
|
||||
});
|
||||
|
||||
test('shows SteamVR indicator', () => {
|
||||
const wrapper = mountStatusBar({ Game: { isSteamVRRunning: true } });
|
||||
expect(wrapper.text()).toContain('SteamVR');
|
||||
});
|
||||
|
||||
test('shows last game session details when game is offline and there is session data', () => {
|
||||
const wrapper = mountStatusBar({
|
||||
Game: {
|
||||
isGameRunning: false,
|
||||
lastSessionDurationMs: 3_600_000,
|
||||
lastOfflineAt: new Date('2026-03-13T14:30:00Z').getTime()
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Last Session');
|
||||
expect(wrapper.text()).toContain('Offline Since');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
timeToText: vi.fn((ms) => `${ms}ms`),
|
||||
nowRef: null
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils', () => ({
|
||||
timeToText: (...args) => mocks.timeToText(...args)
|
||||
}));
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useNow: () => mocks.nowRef
|
||||
}));
|
||||
|
||||
import Timer from '../Timer.vue';
|
||||
|
||||
describe('Timer.vue', () => {
|
||||
beforeEach(() => {
|
||||
mocks.timeToText.mockClear();
|
||||
mocks.nowRef = ref(10000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders elapsed time text from epoch', () => {
|
||||
const wrapper = mount(Timer, {
|
||||
props: {
|
||||
epoch: 4000
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('6000ms');
|
||||
expect(mocks.timeToText).toHaveBeenCalledWith(6000);
|
||||
});
|
||||
|
||||
it('updates text when now value changes', async () => {
|
||||
const wrapper = mount(Timer, {
|
||||
props: {
|
||||
epoch: 4000
|
||||
}
|
||||
});
|
||||
|
||||
mocks.nowRef.value = 13000;
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.text()).toBe('9000ms');
|
||||
});
|
||||
|
||||
it('renders dash when epoch is falsy', () => {
|
||||
const wrapper = mount(Timer, {
|
||||
props: {
|
||||
epoch: 0
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('-');
|
||||
});
|
||||
|
||||
it('computes correct elapsed time', () => {
|
||||
mocks.nowRef.value = 20000;
|
||||
const wrapper = mount(Timer, {
|
||||
props: {
|
||||
epoch: 5000
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('15000ms');
|
||||
expect(mocks.timeToText).toHaveBeenCalledWith(15000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, expect, test, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
defaultVisibility,
|
||||
formatAppUptime,
|
||||
formatUtcHour,
|
||||
loadClockCount,
|
||||
loadClocks,
|
||||
loadVisibility,
|
||||
normalizeClock,
|
||||
normalizeUtcHour,
|
||||
parseClockOffset
|
||||
} from '../statusBarUtils';
|
||||
|
||||
// ─── normalizeUtcHour ────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeUtcHour', () => {
|
||||
test('passes through normal integer values', () => {
|
||||
expect(normalizeUtcHour(0)).toBe(0);
|
||||
expect(normalizeUtcHour(5)).toBe(5);
|
||||
expect(normalizeUtcHour(-5)).toBe(-5);
|
||||
});
|
||||
|
||||
test('clamps to lower bound -12', () => {
|
||||
expect(normalizeUtcHour(-12)).toBe(-12);
|
||||
expect(normalizeUtcHour(-13)).toBe(-12);
|
||||
expect(normalizeUtcHour(-100)).toBe(-12);
|
||||
});
|
||||
|
||||
test('clamps to upper bound 14', () => {
|
||||
expect(normalizeUtcHour(14)).toBe(14);
|
||||
expect(normalizeUtcHour(15)).toBe(14);
|
||||
expect(normalizeUtcHour(100)).toBe(14);
|
||||
});
|
||||
|
||||
test('rounds fractional values', () => {
|
||||
expect(normalizeUtcHour(5.4)).toBe(5);
|
||||
expect(normalizeUtcHour(5.5)).toBe(6);
|
||||
expect(normalizeUtcHour(-5.7)).toBe(-6);
|
||||
});
|
||||
|
||||
test('returns 0 for NaN and Infinity', () => {
|
||||
expect(normalizeUtcHour(NaN)).toBe(0);
|
||||
expect(normalizeUtcHour(Infinity)).toBe(0);
|
||||
expect(normalizeUtcHour(-Infinity)).toBe(0);
|
||||
});
|
||||
|
||||
test('coerces string numbers', () => {
|
||||
expect(normalizeUtcHour('9')).toBe(9);
|
||||
expect(normalizeUtcHour('-3')).toBe(-3);
|
||||
});
|
||||
|
||||
test('returns 0 for non-numeric strings', () => {
|
||||
expect(normalizeUtcHour('abc')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatUtcHour ───────────────────────────────────────────────────
|
||||
|
||||
describe('formatUtcHour', () => {
|
||||
test('formats positive offsets with plus sign', () => {
|
||||
expect(formatUtcHour(9)).toBe('UTC+9');
|
||||
expect(formatUtcHour(14)).toBe('UTC+14');
|
||||
});
|
||||
|
||||
test('formats negative offsets', () => {
|
||||
expect(formatUtcHour(-5)).toBe('UTC-5');
|
||||
expect(formatUtcHour(-12)).toBe('UTC-12');
|
||||
});
|
||||
|
||||
test('formats zero as positive', () => {
|
||||
expect(formatUtcHour(0)).toBe('UTC+0');
|
||||
});
|
||||
|
||||
test('normalises before formatting', () => {
|
||||
expect(formatUtcHour(20)).toBe('UTC+14');
|
||||
expect(formatUtcHour(-20)).toBe('UTC-12');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseClockOffset ────────────────────────────────────────────────
|
||||
|
||||
describe('parseClockOffset', () => {
|
||||
test('parses numeric input', () => {
|
||||
expect(parseClockOffset(9)).toBe(9);
|
||||
expect(parseClockOffset(-3)).toBe(-3);
|
||||
});
|
||||
|
||||
test('parses plain numeric strings', () => {
|
||||
expect(parseClockOffset('5')).toBe(5);
|
||||
expect(parseClockOffset('-7')).toBe(-7);
|
||||
expect(parseClockOffset('+3')).toBe(3);
|
||||
});
|
||||
|
||||
test('parses numeric strings with whitespace', () => {
|
||||
expect(parseClockOffset(' 5 ')).toBe(5);
|
||||
});
|
||||
|
||||
test('parses UTC+N pattern', () => {
|
||||
expect(parseClockOffset('UTC+9')).toBe(9);
|
||||
expect(parseClockOffset('UTC-5')).toBe(-5);
|
||||
expect(parseClockOffset('utc+0')).toBe(0);
|
||||
});
|
||||
|
||||
test('parses UTC pattern with half-hour offset', () => {
|
||||
expect(parseClockOffset('UTC+5:30')).toBe(6); // 5.5 rounds to 6
|
||||
expect(parseClockOffset('UTC-9:30')).toBe(-9); // -9.5 rounds to -9 (Math.round toward +Infinity)
|
||||
});
|
||||
|
||||
test('returns 0 for non-string non-number input', () => {
|
||||
expect(parseClockOffset(null)).toBe(0);
|
||||
expect(parseClockOffset(undefined)).toBe(0);
|
||||
expect(parseClockOffset(true)).toBe(0);
|
||||
expect(parseClockOffset([])).toBe(0);
|
||||
});
|
||||
|
||||
test('returns 0 for unrecognised string patterns', () => {
|
||||
expect(parseClockOffset('not-a-timezone')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeClock ──────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeClock', () => {
|
||||
test('normalises entry with offset key', () => {
|
||||
expect(normalizeClock({ offset: 9 })).toEqual({ offset: 9 });
|
||||
expect(normalizeClock({ offset: '5' })).toEqual({ offset: 5 });
|
||||
});
|
||||
|
||||
test('normalises legacy entry with timezone key', () => {
|
||||
expect(normalizeClock({ timezone: 'UTC+9' })).toEqual({ offset: 9 });
|
||||
});
|
||||
|
||||
test('prefers offset over timezone when both present', () => {
|
||||
expect(normalizeClock({ offset: 3, timezone: 'UTC+9' })).toEqual({
|
||||
offset: 3
|
||||
});
|
||||
});
|
||||
|
||||
test('returns { offset: 0 } for non-object input', () => {
|
||||
expect(normalizeClock(null)).toEqual({ offset: 0 });
|
||||
expect(normalizeClock(undefined)).toEqual({ offset: 0 });
|
||||
expect(normalizeClock(42)).toEqual({ offset: 0 });
|
||||
expect(normalizeClock('string')).toEqual({ offset: 0 });
|
||||
});
|
||||
|
||||
test('returns { offset: 0 } for object without known keys', () => {
|
||||
expect(normalizeClock({ foo: 'bar' })).toEqual({ offset: 0 });
|
||||
expect(normalizeClock({})).toEqual({ offset: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadVisibility ──────────────────────────────────────────────────
|
||||
|
||||
describe('loadVisibility', () => {
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorage();
|
||||
});
|
||||
|
||||
test('returns defaults when storage is empty', () => {
|
||||
expect(loadVisibility(storage)).toEqual(defaultVisibility);
|
||||
});
|
||||
|
||||
test('merges saved values with defaults', () => {
|
||||
storage.setItem(
|
||||
'VRCX_statusBarVisibility',
|
||||
JSON.stringify({ vrchat: false, ws: false })
|
||||
);
|
||||
const result = loadVisibility(storage);
|
||||
expect(result.vrchat).toBe(false);
|
||||
expect(result.ws).toBe(false);
|
||||
// Other defaults preserved
|
||||
expect(result.proxy).toBe(true);
|
||||
expect(result.zoom).toBe(true);
|
||||
});
|
||||
|
||||
test('returns defaults on corrupt JSON', () => {
|
||||
storage.setItem('VRCX_statusBarVisibility', '{bad-json');
|
||||
expect(loadVisibility(storage)).toEqual(defaultVisibility);
|
||||
});
|
||||
|
||||
test('returns a new object each time (no shared reference)', () => {
|
||||
const a = loadVisibility(storage);
|
||||
const b = loadVisibility(storage);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadClocks ──────────────────────────────────────────────────────
|
||||
|
||||
describe('loadClocks', () => {
|
||||
const defaults = [{ offset: 9 }, { offset: 0 }, { offset: -5 }];
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorage();
|
||||
});
|
||||
|
||||
test('returns defaults when storage is empty', () => {
|
||||
const result = loadClocks(storage, defaults);
|
||||
expect(result).toEqual(defaults);
|
||||
});
|
||||
|
||||
test('loads valid saved clocks', () => {
|
||||
storage.setItem(
|
||||
'VRCX_statusBarClocks',
|
||||
JSON.stringify([{ offset: 1 }, { offset: 2 }, { offset: 3 }])
|
||||
);
|
||||
expect(loadClocks(storage, defaults)).toEqual([
|
||||
{ offset: 1 },
|
||||
{ offset: 2 },
|
||||
{ offset: 3 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns defaults for wrong array length', () => {
|
||||
storage.setItem(
|
||||
'VRCX_statusBarClocks',
|
||||
JSON.stringify([{ offset: 1 }])
|
||||
);
|
||||
expect(loadClocks(storage, defaults)).toEqual(defaults);
|
||||
});
|
||||
|
||||
test('returns defaults for non-array JSON', () => {
|
||||
storage.setItem('VRCX_statusBarClocks', JSON.stringify({ offset: 1 }));
|
||||
expect(loadClocks(storage, defaults)).toEqual(defaults);
|
||||
});
|
||||
|
||||
test('returns defaults on corrupt JSON', () => {
|
||||
storage.setItem('VRCX_statusBarClocks', 'not-json');
|
||||
expect(loadClocks(storage, defaults)).toEqual(defaults);
|
||||
});
|
||||
|
||||
test('normalises clock entries from storage', () => {
|
||||
storage.setItem(
|
||||
'VRCX_statusBarClocks',
|
||||
JSON.stringify([
|
||||
{ offset: '5' },
|
||||
{ timezone: 'UTC+3' },
|
||||
{ offset: 99 }
|
||||
])
|
||||
);
|
||||
expect(loadClocks(storage, defaults)).toEqual([
|
||||
{ offset: 5 },
|
||||
{ offset: 3 },
|
||||
{ offset: 14 } // clamped
|
||||
]);
|
||||
});
|
||||
|
||||
test('returned defaults are independent copies', () => {
|
||||
const a = loadClocks(storage, defaults);
|
||||
const b = loadClocks(storage, defaults);
|
||||
expect(a).not.toBe(b);
|
||||
a[0].offset = 999;
|
||||
expect(b[0].offset).not.toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadClockCount ──────────────────────────────────────────────────
|
||||
|
||||
describe('loadClockCount', () => {
|
||||
let storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorage();
|
||||
});
|
||||
|
||||
test('returns 2 when storage is empty', () => {
|
||||
expect(loadClockCount(storage)).toBe(2);
|
||||
});
|
||||
|
||||
test.each([0, 1, 2, 3])('returns valid stored count %i', (n) => {
|
||||
storage.setItem('VRCX_statusBarClockCount', String(n));
|
||||
expect(loadClockCount(storage)).toBe(n);
|
||||
});
|
||||
|
||||
test('returns 2 for out-of-range values', () => {
|
||||
storage.setItem('VRCX_statusBarClockCount', '4');
|
||||
expect(loadClockCount(storage)).toBe(2);
|
||||
|
||||
storage.setItem('VRCX_statusBarClockCount', '-1');
|
||||
expect(loadClockCount(storage)).toBe(2);
|
||||
});
|
||||
|
||||
test('returns 2 for non-numeric values', () => {
|
||||
storage.setItem('VRCX_statusBarClockCount', 'abc');
|
||||
expect(loadClockCount(storage)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatAppUptime ─────────────────────────────────────────────────
|
||||
|
||||
describe('formatAppUptime', () => {
|
||||
test('formats zero seconds', () => {
|
||||
expect(formatAppUptime(0)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
test('formats seconds only', () => {
|
||||
expect(formatAppUptime(45)).toBe('00:00:45');
|
||||
});
|
||||
|
||||
test('formats minutes and seconds', () => {
|
||||
expect(formatAppUptime(125)).toBe('00:02:05');
|
||||
});
|
||||
|
||||
test('formats hours, minutes, and seconds', () => {
|
||||
expect(formatAppUptime(3661)).toBe('01:01:01');
|
||||
});
|
||||
|
||||
test('handles large values (over 24 hours)', () => {
|
||||
// 100 hours = 360000 seconds
|
||||
expect(formatAppUptime(360000)).toBe('100:00:00');
|
||||
});
|
||||
|
||||
test('treats negative values as zero', () => {
|
||||
expect(formatAppUptime(-10)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
test('floors fractional seconds', () => {
|
||||
expect(formatAppUptime(59.9)).toBe('00:00:59');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── test helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Minimal in-memory Storage-like object for testing. */
|
||||
function createMockStorage() {
|
||||
const data = new Map();
|
||||
return {
|
||||
getItem: (key) => (data.has(key) ? data.get(key) : null),
|
||||
setItem: (key, value) => data.set(key, String(value)),
|
||||
removeItem: (key) => data.delete(key),
|
||||
clear: () => data.clear()
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,321 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="changeAvatarImageDialogVisible"
|
||||
@update:open="(open) => {
|
||||
if (!open) closeDialog();
|
||||
}">
|
||||
<DialogContent class="x-dialog sm:max-w-212.5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('dialog.change_content_image.avatar') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<input
|
||||
id="AvatarImageUploadButton"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="onFileChangeAvatarImage" />
|
||||
<span>{{ t('dialog.change_content_image.description') }}</span>
|
||||
<br />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
:disabled="changeAvatarImageDialogLoading"
|
||||
@click="uploadAvatarImage">
|
||||
<Upload />
|
||||
{{ t('dialog.change_content_image.upload') }}
|
||||
</Button>
|
||||
<br />
|
||||
<div class="inline-block p-1 pb-0 hover:rounded-sm">
|
||||
<img :src="previousImageUrl" class="img-size" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Upload } from 'lucide-vue-next';
|
||||
import { ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { avatarRequest, imageRequest } from '../../../api';
|
||||
import { $throw } from '../../../service/request';
|
||||
import { AppDebug } from '../../../service/appConfig';
|
||||
import { extractFileId } from '../../../shared/utils';
|
||||
import { handleImageUploadInput } from '../../../shared/utils/imageUpload';
|
||||
import { useAvatarStore } from '../../../stores';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { avatarDialog } = storeToRefs(useAvatarStore());
|
||||
const { applyAvatar } = useAvatarStore();
|
||||
|
||||
defineProps({
|
||||
changeAvatarImageDialogVisible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
previousImageUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const changeAvatarImageDialogLoading = ref(false);
|
||||
const avatarImage = ref({
|
||||
base64File: '',
|
||||
fileMd5: '',
|
||||
base64SignatureFile: '',
|
||||
signatureMd5: '',
|
||||
fileId: '',
|
||||
avatarId: ''
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:changeAvatarImageDialogVisible', 'update:previousImageUrl']);
|
||||
|
||||
function closeDialog() {
|
||||
emit('update:changeAvatarImageDialogVisible', false);
|
||||
}
|
||||
|
||||
async function resizeImageToFitLimits(file) {
|
||||
const response = await AppApi.ResizeImageToFitLimits(file);
|
||||
return response;
|
||||
}
|
||||
|
||||
function onFileChangeAvatarImage(e) {
|
||||
const { file, clearInput } = handleImageUploadInput(e, {
|
||||
inputSelector: '#AvatarImageUploadButton',
|
||||
tooLargeMessage: () => t('message.file.too_large'),
|
||||
invalidTypeMessage: () => t('message.file.not_image')
|
||||
});
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!avatarDialog.value.visible || avatarDialog.value.loading) {
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
|
||||
const r = new FileReader();
|
||||
const finalize = () => {
|
||||
changeAvatarImageDialogLoading.value = false;
|
||||
clearInput();
|
||||
};
|
||||
r.onerror = finalize;
|
||||
r.onabort = finalize;
|
||||
r.onload = async function () {
|
||||
const uploadPromise = (async () => {
|
||||
const base64File = await resizeImageToFitLimits(btoa(r.result.toString()));
|
||||
// 10MB
|
||||
if (LINUX) {
|
||||
// use new website upload process on Linux, we're missing the needed libraries for Unity method
|
||||
// website method clears avatar name and is missing world image uploading
|
||||
await initiateUpload(base64File);
|
||||
return;
|
||||
}
|
||||
await initiateUploadLegacy(base64File, file);
|
||||
})();
|
||||
toast.promise(uploadPromise, {
|
||||
loading: t('message.upload.loading'),
|
||||
success: t('message.upload.success'),
|
||||
error: t('message.upload.error')
|
||||
});
|
||||
try {
|
||||
await uploadPromise;
|
||||
} catch (error) {
|
||||
console.error('avatar image upload process failed:', error);
|
||||
} finally {
|
||||
finalize();
|
||||
}
|
||||
};
|
||||
|
||||
changeAvatarImageDialogLoading.value = true;
|
||||
try {
|
||||
r.readAsBinaryString(file);
|
||||
} catch (error) {
|
||||
console.error('Failed to read file', error);
|
||||
finalize();
|
||||
}
|
||||
}
|
||||
|
||||
async function initiateUploadLegacy(base64File, file) {
|
||||
const fileMd5 = await AppApi.MD5File(base64File);
|
||||
const fileSizeInBytes = parseInt(file.size, 10);
|
||||
const base64SignatureFile = await AppApi.SignFile(base64File);
|
||||
const signatureMd5 = await AppApi.MD5File(base64SignatureFile);
|
||||
const signatureSizeInBytes = parseInt(await AppApi.FileLength(base64SignatureFile), 10);
|
||||
const avatarId = avatarDialog.value.id;
|
||||
const { imageUrl } = avatarDialog.value.ref;
|
||||
const fileId = extractFileId(imageUrl);
|
||||
avatarImage.value = {
|
||||
base64File,
|
||||
fileMd5,
|
||||
base64SignatureFile,
|
||||
signatureMd5,
|
||||
fileId,
|
||||
avatarId
|
||||
};
|
||||
const params = {
|
||||
fileMd5,
|
||||
fileSizeInBytes,
|
||||
signatureMd5,
|
||||
signatureSizeInBytes
|
||||
};
|
||||
const res = await imageRequest.uploadAvatarImage(params, fileId);
|
||||
return avatarImageInit(res);
|
||||
}
|
||||
|
||||
async function avatarImageInit(args) {
|
||||
const fileId = args.json.id;
|
||||
const fileVersion = args.json.versions[args.json.versions.length - 1].version;
|
||||
const params = {
|
||||
fileId,
|
||||
fileVersion
|
||||
};
|
||||
const res = await imageRequest.uploadAvatarImageFileStart(params);
|
||||
return avatarImageFileStart(res);
|
||||
}
|
||||
|
||||
async function avatarImageFileStart(args) {
|
||||
const { url } = args.json;
|
||||
const { fileId, fileVersion } = args.params;
|
||||
const params = {
|
||||
url,
|
||||
fileId,
|
||||
fileVersion
|
||||
};
|
||||
return uploadAvatarImageFileAWS(params);
|
||||
}
|
||||
|
||||
async function uploadAvatarImageFileAWS(params) {
|
||||
const json = await webApiService.execute({
|
||||
url: params.url,
|
||||
uploadFilePUT: true,
|
||||
fileData: avatarImage.value.base64File,
|
||||
fileMIME: 'image/png',
|
||||
fileMD5: avatarImage.value.fileMd5
|
||||
});
|
||||
|
||||
if (json.status !== 200) {
|
||||
changeAvatarImageDialogLoading.value = false;
|
||||
$throw(json.status, 'avatar image upload failed', params.url);
|
||||
}
|
||||
const args = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
return avatarImageFileAWS(args);
|
||||
}
|
||||
|
||||
async function avatarImageFileAWS(args) {
|
||||
const { fileId, fileVersion } = args.params;
|
||||
const params = {
|
||||
fileId,
|
||||
fileVersion
|
||||
};
|
||||
const res = await imageRequest.uploadAvatarImageFileFinish(params);
|
||||
return avatarImageFileFinish(res);
|
||||
}
|
||||
|
||||
async function avatarImageFileFinish(args) {
|
||||
const { fileId, fileVersion } = args.params;
|
||||
const params = {
|
||||
fileId,
|
||||
fileVersion
|
||||
};
|
||||
const res = await imageRequest.uploadAvatarImageSigStart(params);
|
||||
return avatarImageSigStart(res);
|
||||
}
|
||||
|
||||
async function avatarImageSigStart(args) {
|
||||
const { url } = args.json;
|
||||
const { fileId, fileVersion } = args.params;
|
||||
const params = {
|
||||
url,
|
||||
fileId,
|
||||
fileVersion
|
||||
};
|
||||
return uploadAvatarImageSigAWS(params);
|
||||
}
|
||||
|
||||
async function uploadAvatarImageSigAWS(params) {
|
||||
const json = await webApiService.execute({
|
||||
url: params.url,
|
||||
uploadFilePUT: true,
|
||||
fileData: avatarImage.value.base64SignatureFile,
|
||||
fileMIME: 'application/x-rsync-signature',
|
||||
fileMD5: avatarImage.value.signatureMd5
|
||||
});
|
||||
|
||||
if (json.status !== 200) {
|
||||
changeAvatarImageDialogLoading.value = false;
|
||||
$throw(json.status, 'avatar image upload failed', params.url);
|
||||
}
|
||||
const args = {
|
||||
json,
|
||||
params
|
||||
};
|
||||
return avatarImageSigAWS(args);
|
||||
}
|
||||
|
||||
async function avatarImageSigAWS(args) {
|
||||
const { fileId, fileVersion } = args.params;
|
||||
const params = {
|
||||
fileId,
|
||||
fileVersion
|
||||
};
|
||||
const res = await imageRequest.uploadAvatarImageSigFinish(params);
|
||||
return avatarImageSigFinish(res);
|
||||
}
|
||||
async function avatarImageSigFinish(args) {
|
||||
const { fileId, fileVersion } = args.params;
|
||||
const params = {
|
||||
id: avatarImage.value.avatarId,
|
||||
imageUrl: `${AppDebug.endpointDomain}/file/${fileId}/${fileVersion}/file`
|
||||
};
|
||||
const res = await imageRequest.setAvatarImage(params);
|
||||
return avatarImageSet(res);
|
||||
}
|
||||
|
||||
function avatarImageSet(args) {
|
||||
changeAvatarImageDialogLoading.value = false;
|
||||
if (args.json.imageUrl === args.params.imageUrl) {
|
||||
emit('update:previousImageUrl', args.json.imageUrl);
|
||||
} else {
|
||||
$throw(0, 'avatar image change failed', args.params.imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------ Upload Process End ------------
|
||||
|
||||
async function initiateUpload(base64File) {
|
||||
const args = await avatarRequest.uploadAvatarImage(base64File);
|
||||
const fileUrl = args.json.versions[args.json.versions.length - 1].file.url;
|
||||
const avatarArgs = await avatarRequest.saveAvatar({
|
||||
id: avatarDialog.value.id,
|
||||
imageUrl: fileUrl
|
||||
});
|
||||
const ref = applyAvatar(avatarArgs.json);
|
||||
changeAvatarImageDialogLoading.value = false;
|
||||
emit('update:previousImageUrl', ref.imageUrl);
|
||||
|
||||
// closeDialog();
|
||||
}
|
||||
|
||||
function uploadAvatarImage() {
|
||||
document.getElementById('AvatarImageUploadButton').click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.img-size {
|
||||
width: 500px;
|
||||
height: 375px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="setAvatarStylesDialog.visible"
|
||||
@update:open="(open) => {
|
||||
if (!open) closeSetAvatarStylesDialog();
|
||||
}">
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open) closeSetAvatarStylesDialog();
|
||||
}
|
||||
">
|
||||
<DialogContent class="x-dialog sm:max-w-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('dialog.set_avatar_styles.header') }}</DialogTitle>
|
||||
@@ -11,61 +13,60 @@
|
||||
|
||||
<template v-if="setAvatarStylesDialog.visible">
|
||||
<div>
|
||||
<span>{{ t('dialog.set_avatar_styles.primary_style') }}</span>
|
||||
<span>{{ t('dialog.set_avatar_styles.primary_style') }}</span>
|
||||
<br />
|
||||
<Select
|
||||
:model-value="setAvatarStylesDialog.primaryStyle"
|
||||
@update:modelValue="(v) => updateDialog({ primaryStyle: v === SELECT_CLEAR_VALUE ? '' : v })">
|
||||
<SelectTrigger size="sm" style="display: inline-flex">
|
||||
<SelectValue :placeholder="t('dialog.set_avatar_styles.select_style')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="SELECT_CLEAR_VALUE">{{ t('dialog.gallery_select.none') }}</SelectItem>
|
||||
<SelectItem
|
||||
v-for="(style, index) in setAvatarStylesDialog.availableAvatarStyles"
|
||||
:key="index"
|
||||
:value="style">
|
||||
{{ style }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<Select
|
||||
:model-value="setAvatarStylesDialog.primaryStyle"
|
||||
@update:modelValue="(v) => updateDialog({ primaryStyle: v === SELECT_CLEAR_VALUE ? '' : v })">
|
||||
<SelectTrigger size="sm" style="display: inline-flex">
|
||||
<SelectValue :placeholder="t('dialog.set_avatar_styles.select_style')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="SELECT_CLEAR_VALUE">{{ t('dialog.gallery_select.none') }}</SelectItem>
|
||||
<SelectItem
|
||||
v-for="(style, index) in setAvatarStylesDialog.availableAvatarStyles"
|
||||
:key="index"
|
||||
:value="style">
|
||||
{{ style }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<div>
|
||||
<span>{{ t('dialog.set_avatar_styles.secondary_style') }}</span>
|
||||
<br />
|
||||
<Select
|
||||
:model-value="setAvatarStylesDialog.secondaryStyle"
|
||||
@update:modelValue="(v) => updateDialog({ secondaryStyle: v === SELECT_CLEAR_VALUE ? '' : v })">
|
||||
<SelectTrigger size="sm" style="display: inline-flex">
|
||||
<SelectValue :placeholder="t('dialog.set_avatar_styles.select_style')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="SELECT_CLEAR_VALUE">{{ t('dialog.gallery_select.none') }}</SelectItem>
|
||||
<SelectItem
|
||||
v-for="(style, index) in setAvatarStylesDialog.availableAvatarStyles"
|
||||
:key="index"
|
||||
:value="style">
|
||||
{{ style }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span>{{ t('dialog.set_avatar_styles.secondary_style') }}</span>
|
||||
<br />
|
||||
<Select
|
||||
:model-value="setAvatarStylesDialog.secondaryStyle"
|
||||
@update:modelValue="(v) => updateDialog({ secondaryStyle: v === SELECT_CLEAR_VALUE ? '' : v })">
|
||||
<SelectTrigger size="sm" style="display: inline-flex">
|
||||
<SelectValue :placeholder="t('dialog.set_avatar_styles.select_style')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="SELECT_CLEAR_VALUE">{{ t('dialog.gallery_select.none') }}</SelectItem>
|
||||
<SelectItem
|
||||
v-for="(style, index) in setAvatarStylesDialog.availableAvatarStyles"
|
||||
:key="index"
|
||||
:value="style">
|
||||
{{ style }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<div class="text-xs">{{ t('dialog.set_world_tags.author_tags') }}</div>
|
||||
|
||||
<div style="font-size: 12px">{{ t('dialog.set_world_tags.author_tags') }}</div>
|
||||
|
||||
<InputGroupTextareaField
|
||||
:model-value="setAvatarStylesDialog.authorTags"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
:rows="2"
|
||||
placeholder=""
|
||||
style="margin-top: 10px"
|
||||
input-class="resize-none"
|
||||
@update:modelValue="(v) => updateDialog({ authorTags: v })" />
|
||||
<InputGroupTextareaField
|
||||
:model-value="setAvatarStylesDialog.authorTags"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
:rows="2"
|
||||
placeholder=""
|
||||
input-class="resize-none mt-2"
|
||||
@update:modelValue="(v) => updateDialog({ authorTags: v })" />
|
||||
</template>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -81,17 +82,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputGroupTextareaField } from '@/components/ui/input-group';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { watch } from 'vue';
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
|
||||
import { avatarRequest, queryRequest } from '../../../api';
|
||||
import { arraysMatch } from '../../../shared/utils';
|
||||
import { avatarRequest } from '../../../api';
|
||||
import { useAvatarStore } from '../../../stores';
|
||||
import { applyAvatar } from '../../../coordinators/avatarCoordinator';
|
||||
|
||||
const props = defineProps({
|
||||
setAvatarStylesDialog: {
|
||||
@@ -103,7 +105,6 @@
|
||||
const emit = defineEmits(['update:setAvatarStylesDialog']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { applyAvatar } = useAvatarStore();
|
||||
|
||||
const SELECT_CLEAR_VALUE = '__clear__';
|
||||
|
||||
@@ -116,6 +117,10 @@
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param patch
|
||||
*/
|
||||
function updateDialog(patch) {
|
||||
emit('update:setAvatarStylesDialog', {
|
||||
...props.setAvatarStylesDialog,
|
||||
@@ -123,9 +128,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function getAvatarStyles() {
|
||||
try {
|
||||
const ref = await avatarRequest.getAvailableAvatarStyles();
|
||||
const ref = await queryRequest.fetch('avatarStyles');
|
||||
const styles = [];
|
||||
const stylesMap = new Map();
|
||||
for (const style of ref.json) {
|
||||
@@ -142,10 +150,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function closeSetAvatarStylesDialog() {
|
||||
updateDialog({ visible: false });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function saveSetAvatarStylesDialog() {
|
||||
const primaryStyleId =
|
||||
props.setAvatarStylesDialog.availableAvatarStylesMap.get(props.setAvatarStylesDialog.primaryStyle) || '';
|
||||
@@ -199,5 +213,3 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user