Compare commits

...

640 Commits

Author SHA1 Message Date
pa 71e5897e54 adjust gamelog widget video play event icon 2026-04-03 20:28:45 +09:00
XoFKon 74e5eaf535 Update Traditional Chinese (#1716) 2026-04-03 18:33:17 +09:00
pa 92ae1c5531 improve quick search names responsiveness by allowing single character queries 2026-03-31 19:52:28 +09:00
pa bba33cd103 fix: Cannot access 'tray' before initialization 2026-03-31 13:59:26 +09:00
pa a7930ff6fc adjust badge variant for age-gated instances (#1712) 2026-03-30 22:26:40 +09:00
pa aefdc9c82e fix: split i18n when build and npm audit fix 2026-03-30 21:02:01 +09:00
pa c5b8a32b87 SetCompressorDictSize 16 2026-03-30 15:13:42 +09:00
pa 0908bdf4cf fix: i18n key in status bar 2026-03-30 09:50:39 +09:00
pa af9ecc5c18 fix: tray icon cannot be destroyed when app restart or exit in linux 2026-03-30 09:50:38 +09:00
pa 0fe9a11482 upgrade CefSharp to 146.0.70 2026-03-30 08:17:44 +09:00
pa 640efbd276 fix: Auto Accept Invite Requests and adjust text 2026-03-30 08:17:43 +09:00
pa e156bdfee8 adjust onboard dialog ui 2026-03-30 08:17:41 +09:00
pa 2511fc080d fix comma 2026-03-29 18:25:56 +09:00
XoFKon 6378145a8f Update Traditional Chinese (#1693)
Co-authored-by: Map1en <maplenagisa@gmail.com>
2026-03-29 18:19:37 +09:00
pa 7fc4612a66 adujst mutual graph last fetch time format 2026-03-29 18:13:18 +09:00
pa a9cc6a2ce3 adjust direct access kbd gap 2026-03-29 18:13:17 +09:00
pa f3772a3acb fix: friend sidebar overflow 2026-03-29 18:13:16 +09:00
pa 551c7993c6 fix: tts friend's name change bug 2026-03-29 18:13:16 +09:00
pa 2be92cc45f fix: emoji card layout 2026-03-28 17:49:24 +09:00
pa 2c3e69a215 feat: add some context menu to game log page 2026-03-28 17:49:14 +09:00
pa 91ac716c34 fix: emoji size in gallery and avatar dialog 2026-03-28 17:49:13 +09:00
pa 0635378cef fix: Destroy tray on quit 2026-03-28 17:49:13 +09:00
Drelaky 5f077effcc feat: added hungarian readme file (#1710)
* fix: updated hungarian translation

* fix: updated hungarian translation

* feat: added hungarian readme file

* Merge branch 'master' of github.com:Drelaky/VRCX-hu-translate
2026-03-28 12:15:44 +09:00
Yuki 3dc7b07dd4 Update Japanese translation and add localization support in notification (#1706) 2026-03-27 13:31:12 +09:00
inex 1e5cc91fc2 Update pt.json (#1701)
Everything translated/retranslated... some terms adapted to match those used in VRChat.
2026-03-27 13:28:30 +09:00
Drelaky 71709f1944 fix: updated hungarian translation (#1699)
* fix: updated hungarian translation

* fix: updated hungarian translation
2026-03-27 13:28:11 +09:00
Antonin Léonard b2498f0729 Update - Translation French (#1668) 2026-03-27 13:27:59 +09:00
pa 4cfb0973bc bump 2026-03-27 13:19:53 +09:00
pa f022ee6210 fix: cropper cannot output transparent image 2026-03-27 13:19:53 +09:00
pa 4da9e1cf46 fix: gallery animation emoji layout 2026-03-27 13:19:52 +09:00
pa 9b2eb7ea36 improve ui 2026-03-27 13:19:51 +09:00
pa f71ac77377 fix: load mutual friends button and mutual opt-out status in friend list 2026-03-27 13:19:51 +09:00
pa b3b1d68cc9 fix: new instance dialog user ID handling for legacy instances 2026-03-27 13:19:50 +09:00
pa c504c71191 fixes 2026-03-27 13:19:46 +09:00
pa 62a54922e7 update stale.yaml 2026-03-26 15:14:54 +09:00
pa 1a7f9ca952 fix: location context menu does not remove external classes from location node 2026-03-26 00:13:47 +09:00
pa 807e39ce72 fix: gamelog widget location display issues 2026-03-26 00:13:47 +09:00
pa a7b578e4cf fix: feed widget layout issues and add truncation to location names 2026-03-26 00:13:46 +09:00
pa a51b0fd703 fix: Update getLastGroupVisit to use groupId and adjust query accordingly 2026-03-26 00:13:46 +09:00
pa 50b7005cf8 improve vr overlay ui 2026-03-26 00:13:45 +09:00
pa 15a7d66cf6 improve mutual friends settings add clear button to virtual combobox 2026-03-26 00:13:45 +09:00
pa 6b728951fa feat: add option to refresh mutual friends data for individual nodes in the graph context menu 2026-03-26 00:13:44 +09:00
pa a811100038 feat: Add context menu to mutual friends graph nodes 2026-03-26 00:13:43 +09:00
pa 512648da6d update stale yml 2026-03-25 14:57:47 +09:00
pa 274a771d8b add actions/stale 2026-03-25 14:36:20 +09:00
XoFKon 4050b310c9 Update Traditional Chinese (#1665) 2026-03-25 13:56:31 +09:00
pa 8c13d14ed4 adjust settings text 2026-03-25 13:13:28 +09:00
pa 305d54eb8b fix: view more button in notification center should navigate to notification page 2026-03-25 09:28:13 +09:00
pa fc1c62d7c2 adjust changelog menu item text and event name 2026-03-24 20:13:16 +09:00
pa 88699cb233 ui 2026-03-24 20:13:16 +09:00
pa 7e9d46ffde remove unnecessary dependency 2026-03-24 20:13:15 +09:00
pa 6b2afc1b34 fix: Don't show favicon for empty bio links 2026-03-24 20:13:11 +09:00
pa 26951a57c8 fix: Add confirmation message for sending invite request 2026-03-24 12:54:03 +09:00
pa e9743d0a73 adjust Location component to optionally enable context menu 2026-03-24 12:48:02 +09:00
pa d4dd04608b fix 2026-03-24 11:40:21 +09:00
pa 003e0a511e add more context menu 2026-03-24 11:32:13 +09:00
pa bb5a01ae49 feat: add notification layout setting 2026-03-24 10:22:09 +09:00
pa 0af5e33684 adjust whats new dialog word 2026-03-24 09:51:38 +09:00
pa 468696dfce use soild lzma compressor to nsis 2026-03-24 09:16:34 +09:00
pa e8053612ff adjust New dashboard button ui 2026-03-24 09:16:34 +09:00
pa 4b42ab7479 improve friend sidebar settings ui 2026-03-24 09:16:34 +09:00
pa 6b1b21c8d5 improve word 2026-03-24 09:16:34 +09:00
pa 7468263627 bump electron to 40 2026-03-24 09:16:33 +09:00
pa 7e7dc66743 upgrade Cef 145 2026-03-24 09:16:33 +09:00
pa 8735d7bb46 tweak vite config 2026-03-24 09:16:33 +09:00
pa 1c346d82bc feat: add troubleshooting hint after multiple 401 login failures 2026-03-23 21:43:33 +09:00
pa 12b7423716 improve friend sidebar settings UI with sections and collapsible advanced options 2026-03-23 21:43:32 +09:00
pa 296e254718 improve settings ui 2026-03-23 21:43:31 +09:00
pa e6ec7e6150 refactor: use dialog for favorite friend group order settings instead of sheet 2026-03-23 21:43:31 +09:00
pa 387d38a496 adjust navigation menu visibility logic and related UI text 2026-03-23 21:43:30 +09:00
pa a05a17879c change whats new menu item to show latest whats new dialog instead of change log 2026-03-23 21:43:29 +09:00
pa 3e9bff2f1b imrove friend list search responsiveness and stats refresh logic 2026-03-23 21:43:29 +09:00
pa 1895d0f25c improve avatar time spent loading performance by fetching all times in one query 2026-03-23 21:43:28 +09:00
pa 369f5130b5 add peristence for activity tab settings and restore on tab open 2026-03-23 21:43:27 +09:00
pa 520c41f280 feat: add Exclude home world for activity tab 2026-03-23 21:43:27 +09:00
pa fb4750e9bc word 2026-03-23 21:43:26 +09:00
pa 2735fcd749 debounce 2026-03-23 21:43:25 +09:00
pa d28aa497c5 clean up sql 2026-03-23 21:43:25 +09:00
pa 27a159c30c refactor top worlds 2026-03-23 21:43:24 +09:00
pa f2050dc520 fix: activity tab 2026-03-23 21:43:23 +09:00
pa 7d4a229d1f upgrade db version 2026-03-23 21:43:23 +09:00
pa e08de71e96 improve whats new dialog 2026-03-23 21:43:22 +09:00
pa deb27c3fa6 change default clock count to 2 2026-03-23 21:43:21 +09:00
pa 59d8a19c37 add whats new dialog 2026-03-23 21:43:20 +09:00
pa 31e2d7da89 fix: heatmap overflow 2026-03-23 21:43:20 +09:00
pa 297e81f32c auto-login when db upgraded 2026-03-23 21:43:19 +09:00
pa 4a8418a0b3 improve heatmap ui 2026-03-23 21:43:18 +09:00
pa 03ebf7ea27 add last 180 day option to activity tab 2026-03-23 21:43:18 +09:00
pa 367cb3ed28 fix: overlap charts can not display 2026-03-23 21:43:17 +09:00
pa 5ddb23a9e0 fix: VRCPlus Profile Icons setting not functional 2026-03-23 21:43:16 +09:00
pa e5ea66e5d5 refactor activity tab 2026-03-23 21:43:16 +09:00
pa 046730215e fix: VRCPlus Profile Icons setting not functional 2026-03-23 21:43:12 +09:00
pa 163d75aa66 fix: Copy URL under Avatar description is non functional (#1698) 2026-03-20 17:57:05 +09:00
pa ae212dca17 improve activity tab ui 2026-03-20 17:57:05 +09:00
pa ad5b9ab48d improve activity tab performance by adding indexes 2026-03-20 17:57:02 +09:00
pa 4570f254ea update CONTRIBUTING.md 2026-03-20 15:04:17 +09:00
pa b1bfb982d6 update CONTRIBUTING.md 2026-03-20 14:57:07 +09:00
pa 15fc0bdf1b fix: add activity store and user activity caching 2026-03-20 13:34:29 +09:00
pa fbfaf7b93c improve dialog layout 2026-03-20 13:34:29 +09:00
pa f9d3f7089b chore: improve third-party license compliance 2026-03-20 13:34:19 +09:00
pa 6618966ebc feat: add "Most Visited Worlds" section to Activity tab 2026-03-19 18:42:46 +09:00
pa bbb7d596bb feat: Add week start day setting and apply to calendars and charts 2026-03-19 18:42:45 +09:00
pa f980b0ee6e fix: Clear incoming friend request status on accept/expire 2026-03-19 18:42:44 +09:00
pa 4eb781eaff word 2026-03-19 18:42:43 +09:00
pa cb7d6b78b3 adjust lint config 2026-03-19 18:42:43 +09:00
pa 04ebfd0e78 add oxlint and bump 2026-03-19 18:42:41 +09:00
pa 6c1058a9d5 feat: Add online overlap visualization in user activity tab 2026-03-19 18:42:41 +09:00
pa 41ff04b49f word 2026-03-19 18:42:39 +09:00
pa ed584a29a3 adjust status icon shadow and add description for age gated instances setting 2026-03-19 18:42:38 +09:00
pa 6151faf64b uncommit changes related to Avatar DB log cleanup feature 2026-03-19 18:42:37 +09:00
pa 3fbcf5b6ef add "Add age-restricted instance display logic to Location component" (#1629) 2026-03-19 18:42:37 +09:00
pa 647a902e9f fix: reset page index when total page count shrinks below current page 2026-03-19 18:42:36 +09:00
pa 91cfbefd40 fix: user status class not updating in user summary header 2026-03-19 18:42:36 +09:00
pa 475ed452d2 improve user dialog activity style 2026-03-19 18:42:31 +09:00
pa 92afc31ea5 settings tab word 2026-03-18 15:00:03 +09:00
pa de5a6a07fd age gated style 2026-03-18 15:00:03 +09:00
pa 8d5e1fc7f9 fix: Deleted friends/unfriends on the Friend Log will reappear when you close and open the app again (#1262) 2026-03-18 15:00:03 +09:00
pa 621f53e00f fix: favorites world search result style 2026-03-18 15:00:02 +09:00
pa 7dbefdb951 feat: Add favorites world search by tag (#1275) 2026-03-18 15:00:02 +09:00
pa 0292cbb80c fix: playerlist usertimer reset when befriending a user (#837) 2026-03-18 15:00:02 +09:00
pa e05da92d1b feat: Block specific navigation and tool panels from being added to the dashboard 2026-03-18 15:00:02 +09:00
pa 4d8a9dc6dc feat: Introduce an onboarding welcome dialog to highlight key features for new users 2026-03-18 15:00:02 +09:00
pa 831a827ef7 feat: Implement compact pagination/table header styling for data tables within dashboard panels. 2026-03-18 15:00:02 +09:00
pa ab596a13b9 refactor: remove useDataTableScrollHeight and introducing an auto-height prop and CSS class. 2026-03-18 15:00:02 +09:00
pa b33821ba82 adjust friend status icon styles 2026-03-18 15:00:01 +09:00
pa 1a16a2116a disable avatar DB log cleanup UI and functionality 2026-03-18 15:00:01 +09:00
pa ded9ce6da2 adjust widget header padding and table styles for better display 2026-03-18 15:00:01 +09:00
pa 93f07e8877 fix: Override Command filter for all queries so Worker results are not hidden, maybe 2026-03-18 15:00:01 +09:00
pa 62a76330ca feat: add descriptions to settings items 2026-03-18 15:00:00 +09:00
pa cce4520a1a feat: Add option for official VRChat status colors 2026-03-17 20:56:37 +09:00
pa 56e7f910ef feat: Add accessible status indicators setting 2026-03-17 20:56:36 +09:00
pa 120a4c3533 feat: add system language detection and prompt on first launch 2026-03-17 20:56:35 +09:00
pa 6720f1a294 feat: display a pending update indicator on the VRCX update button in the login view. 2026-03-17 20:56:34 +09:00
pa 12215e6a4a fix: Add @select.prevent to ContextMenuCheckboxItems in the status bar and data table to prevent unintended context menu closure. 2026-03-17 20:56:33 +09:00
pa e4c5959685 feat: Add defaultHidden column meta property and update reset logic to hide columns 2026-03-17 20:56:32 +09:00
pa 4e6b23cee8 feat: Add setting to control the visibility of the "New Dashboard" button 2026-03-17 20:56:31 +09:00
pa 91e7e8e1b6 refactor: reorganize settings tabs 2026-03-17 20:56:30 +09:00
pa f582135303 temporary disabled hot-worlds 2026-03-17 20:56:30 +09:00
pa a815e88933 fix 2026-03-17 20:56:29 +09:00
pa 9f306399d1 feat: Convert "Add Favorite Friends" button to a dropdown menu, allowing users to select and invite online friends from specific favorite groups 2026-03-17 20:56:28 +09:00
pa 20b0996915 fix purge avatar feed dialog visible 2026-03-17 20:56:27 +09:00
pa 5e95d142f0 feat: add hot worlds 2026-03-17 20:56:25 +09:00
pa 357ac1a8bb add avatar feed database cleanup settings and purge function 2026-03-17 20:56:25 +09:00
pa a8a14ae901 add previous instances info chart view 2026-03-17 20:56:24 +09:00
pa cfda4c49d1 add activity period filter to user dialog heatmap 2026-03-17 20:56:23 +09:00
pa 54572f9480 Add recent action indicators to friend context menu 2026-03-17 20:56:23 +09:00
pa 1b3e292883 improve friendlist ui 2026-03-17 20:56:22 +09:00
pa 12e47cc246 fix mutual friends combox 2026-03-17 20:56:18 +09:00
pa fadead9c80 fix test 2026-03-16 16:01:38 +09:00
pa 4c6f80277e update README.md 2026-03-16 16:01:35 +09:00
pa be61239529 add contributing guidelines 2026-03-16 15:47:15 +09:00
pa d812db9872 feat: add activity heatmap to user dialog for online frequency visualization (#1198) 2026-03-16 15:47:11 +09:00
pa bfcd3a0de2 add Alert for VRChat server status issues on login page (#1396) 2026-03-16 14:17:20 +09:00
pa eeb50f15b5 feat: add option to prioritize same instance friends in sidebar (#1238) 2026-03-16 14:17:18 +09:00
pa 2a5039b6c9 feat: add option to sort favorites world by player count (#674) 2026-03-16 14:17:16 +09:00
pa 9bf380f2fc feat: Add recent action indicators for invites and friend requests (#809) 2026-03-16 14:17:15 +09:00
pa 03bb1b5410 add search for userdialog tabs (#476) 2026-03-16 14:17:12 +09:00
pa ed1db05d94 feat: add social status presets (#252) 2026-03-16 12:21:00 +09:00
pa dcec53cdc3 improve settings ui 2026-03-16 12:20:59 +09:00
pa 8e3c1e0054 improve appearance settings ui 2026-03-16 12:20:56 +09:00
pa 1bac1e34d6 improve ui 2026-03-16 10:45:06 +09:00
pa 12bb62702f refactor screenshot manager 2026-03-16 10:45:05 +09:00
pa 02c792c09f fix 2026-03-16 10:45:04 +09:00
pa e3ea9881cc improve: Add major issue indicator to VRC status 2026-03-16 10:45:03 +09:00
pa 7c3af2ba6f improve vr ui 2026-03-16 10:45:01 +09:00
pa 63be5d2f7a vite config 2026-03-16 10:45:00 +09:00
pa 4279595c20 style 2026-03-16 10:45:00 +09:00
pa 0f738f25aa refactor gallery 2026-03-16 10:44:59 +09:00
pa 113b9e6b4a fonts 2026-03-16 10:44:58 +09:00
pa 82339adff3 word 2026-03-16 10:44:56 +09:00
pa 9e0116fce7 use vueuse 2026-03-16 10:44:54 +09:00
pa 8624ac20fa refactor 2026-03-16 10:44:53 +09:00
pa 1d7e41a4a1 replace async component imports 2026-03-16 10:44:52 +09:00
pa 91c056b5a3 improve performance and clean up 2026-03-16 10:44:50 +09:00
pa af389e645d feat: add tool nav pinning and unpinning 2026-03-16 10:44:48 +09:00
pa d0f8fbfada refactor useNavLayout 2026-03-16 10:44:48 +09:00
pa cc08f29800 add img fallback 2026-03-16 10:44:43 +09:00
pa 0357e81a78 worker inline 2026-03-14 22:41:51 +09:00
Natsumi cde18c653c Auto launch Steam shortcut fixes 2026-03-14 22:41:51 +09:00
pa d59a0a3894 bump 2026-03-14 22:41:50 +09:00
pa a64f4f6d7a refactor findUserByDisplayName 2026-03-14 22:41:49 +09:00
pa a314885bff fix: Add HMD AFK state update flow and IPC bridge function 2026-03-14 22:41:47 +09:00
pa 9f59a1db8d fix: can not open world dialog when click title 2026-03-14 22:41:46 +09:00
pa a8d1b7a905 refactor friends sort 2026-03-14 22:41:45 +09:00
pa 84f46a5645 rename globalSearch 2026-03-14 22:41:44 +09:00
pa b750d3fb9a refactor global search 2026-03-14 22:41:44 +09:00
pa 45f3eacf21 fix globalsearch perf issue 2026-03-14 22:41:42 +09:00
pa 1f5acd546d add destructive variant to alert dialogs for destructive actions 2026-03-14 22:41:40 +09:00
pa 9b6ca42d9d add game session tracking and display in status bar 2026-03-14 22:41:39 +09:00
pa 7b7c1b4568 use oxfmt instead of prettier 2026-03-14 22:41:38 +09:00
pa 82122a4fab refactor: custom fonts 2026-03-14 22:41:37 +09:00
pa 9ac18ac79e improve dashboard widget 2026-03-14 22:41:24 +09:00
pa 94000a5cc4 fix storeToRefs 2026-03-13 20:05:27 +09:00
pa b1056df80d improve add new dashboard behavior 2026-03-13 20:05:26 +09:00
Natsumi 8def445ba7 storeToRefs 2026-03-13 20:05:24 +09:00
pa 9afb13318f update i18n 2026-03-13 20:05:23 +09:00
Natsumi 8b27dc2770 Fix 2026-03-13 20:05:22 +09:00
pa 73daff5937 fix 2026-03-13 20:05:22 +09:00
pa ff999896b2 use worker 2026-03-13 20:05:21 +09:00
pa 4d131703e7 improve dashboard wideget 2026-03-13 20:05:20 +09:00
pa 36ee0feb36 improve fedd widget 2026-03-13 20:05:18 +09:00
pa 843c53c065 fix vitest config 2026-03-13 20:05:17 +09:00
pa 1ffb2c8b95 add dashboard widget 2026-03-13 20:05:16 +09:00
pa 0135d9bb29 improve dashboard 2026-03-13 20:05:15 +09:00
pa 044c1a42d4 fix: can not display instance player count 2026-03-13 20:05:14 +09:00
pa d2bae2301b fix: can not view group members 2026-03-13 20:05:13 +09:00
pa bee8c0af8e fix: Access friendLogTable data through its value property. 2026-03-13 20:05:13 +09:00
pa 5abf882c94 overrides @tailwindcss/vite 2026-03-13 20:05:12 +09:00
pa ea98de6244 tweak vite config 2026-03-13 20:05:09 +09:00
pa b6c4e65559 fix buld warning 2026-03-13 20:05:08 +09:00
pa c2854edabe bump to vite 8 2026-03-13 20:05:07 +09:00
pa a2ab3a4025 bump 2026-03-13 20:05:06 +09:00
pa bb32b6e92b add vertical panel option to dashboard settings page 2026-03-13 20:05:06 +09:00
pa 73493cb0aa fix error text 2026-03-13 20:05:05 +09:00
pa fbe290b788 remove hide status setting 2026-03-13 20:05:04 +09:00
pa b10ceb9278 fix fonts config 2026-03-13 20:05:03 +09:00
pa 0b95d4f9a9 improve login page ui 2026-03-13 20:05:02 +09:00
pa e817d7392f feat: add dashboard 2026-03-13 20:05:01 +09:00
pa 6e8f9543eb update .gitignore 2026-03-13 20:05:00 +09:00
pa 6c8ed126b1 fix custom nav dialog restore default i18n issue 2026-03-13 20:04:59 +09:00
pa 76ff4844db improve search tab 2026-03-13 20:04:58 +09:00
pa 08e160ff69 add test 2026-03-13 20:04:56 +09:00
pa c72209f56d fix style 2026-03-13 20:04:56 +09:00
pa 4b7db5f890 style 2026-03-13 20:04:55 +09:00
pa 884744cb30 cleanup 2026-03-13 20:04:54 +09:00
pa bf9b66bdf4 fix style 2026-03-13 20:04:53 +09:00
pa b51aef91cb friends locations card style 2026-03-13 20:04:52 +09:00
pa c66b42f03b friends locations card style 2026-03-13 20:04:51 +09:00
pa daf6681435 refactor: use item component for favorites avatar item 2026-03-13 20:04:50 +09:00
pa 50ef184fa4 refactor: use item component for favorites world item 2026-03-13 20:04:49 +09:00
pa 53654c2982 refactor: use item for favorite friend item 2026-03-13 20:04:48 +09:00
pa f757366121 use item component for tools item 2026-03-13 20:04:47 +09:00
pa 8ed3cff0e9 query policies 2026-03-13 20:04:45 +09:00
pa a75c4b89f8 refactor: Introduce granular query types with specific policies for improved caching and data freshness. 2026-03-13 20:04:44 +09:00
pa 14d73b1532 fix 2026-03-13 20:04:43 +09:00
pa 0234abcca3 rewrite web request logging function 2026-03-13 20:04:41 +09:00
pa 1c9e4621f5 fix 2026-03-13 20:04:39 +09:00
pa 607e09d271 eslint config 2026-03-13 20:04:38 +09:00
pa 4877010006 eslint fix 2026-03-13 20:04:37 +09:00
pa 699bf620e5 add new composables for instance and search functionalities 2026-03-13 20:04:36 +09:00
pa fe176f22ff refactor untils 2026-03-13 20:04:36 +09:00
pa 1dfd0bf54c refactor utils 2026-03-13 20:04:34 +09:00
pa ff1529920b rename 2026-03-13 20:04:32 +09:00
pa 17b582c904 fix test 2026-03-13 20:04:31 +09:00
pa 1cbad7fb60 refactor store 2026-03-13 20:04:30 +09:00
pa 95c4a1d3e6 refactor store 2026-03-13 20:04:26 +09:00
pa d7220baaf6 refactor store 2026-03-13 20:04:25 +09:00
pa 2fffadfbcf refactor coordinators 2026-03-13 20:04:24 +09:00
pa 648fcde085 move coordinators folder 2026-03-13 20:04:11 +09:00
pa d0a52ecd23 i18n 2026-03-10 00:21:45 +09:00
pa a2078c5780 style 2026-03-10 00:03:30 +09:00
pa 21489fb717 refactor queryRequest 2026-03-10 00:03:27 +09:00
pa 8f802ecf28 add warning about publish to labs frequency limit (#1680) 2026-03-10 00:03:26 +09:00
pa 163b5b0127 refactor dialog commands func 2026-03-10 00:03:25 +09:00
pa ca57cd6590 refactor query requests to use queryRequest module 2026-03-10 00:03:24 +09:00
pa 58b9bdc1c5 refactor queryRequest 2026-03-10 00:03:20 +09:00
pa c1a35223d4 merge group api request 2026-03-09 20:51:41 +09:00
pa feb04b036f replace inline styles with utility classes in dialogs 2026-03-09 20:50:18 +09:00
pa e5500f47be clean up noty and remove animate.css 2026-03-09 20:50:16 +09:00
pa 3dadc84179 refactor css and add UI Standards class 2026-03-09 20:50:15 +09:00
pa 493713b79a i18n 2026-03-09 20:50:14 +09:00
pa d2d3dc8f13 refactor 2026-03-09 20:50:13 +09:00
pa c26c562d0e add vrchat servers status to status bar 2026-03-09 20:50:12 +09:00
pa bc5db58b89 refactor favorites tab 2026-03-09 20:50:11 +09:00
pa cd832fb96a add test 2026-03-09 20:50:09 +09:00
pa 90a17bb0ba refactor 2026-03-09 20:50:08 +09:00
pa 64b27ce7f1 split user dialog 2026-03-09 20:50:07 +09:00
pa 6f94ee9aab add json tab component unit test 2026-03-09 20:50:07 +09:00
pa 5f2de3d633 split json tab 2026-03-09 20:50:06 +09:00
pa ec88fb9fbe clean up deps 2026-03-09 20:50:03 +09:00
pa 34d10fd59b fix nav menu notification dot 2026-03-08 23:59:18 +09:00
pa faaddaca29 Use classes to prevent Tailwind from tree-shaking 2026-03-08 23:59:17 +09:00
pa 3106d77a71 move test 2026-03-08 23:59:16 +09:00
pa 914642154f Introduce coordinator 2026-03-08 23:59:15 +09:00
pa 7a2bbf0ce2 refactor css 2026-03-08 23:59:13 +09:00
pa 9b564303a4 use tailwind class 2026-03-08 23:59:11 +09:00
pa be2f07f24e use tailwind tokens for border-radius values 2026-03-08 23:59:10 +09:00
pa ff47255e07 use tailwind token for border radius instead of hardcoding values 2026-03-08 23:59:10 +09:00
pa a1090fc064 adjust tailwind token 2026-03-08 23:59:09 +09:00
pa 47807db8cb refactor css 2026-03-08 23:59:06 +09:00
pa 8c21ecd9f0 add eslint rule to prevent direct store state mutation 2026-03-08 23:59:05 +09:00
pa ddee396376 refactor css 2026-03-08 23:59:04 +09:00
pa 2b2dbc898e refactor css 2026-03-08 23:59:04 +09:00
pa c55d5f0ec7 refactor: use setter functions for dialog visibility and favorite status in stores 2026-03-08 23:59:03 +09:00
pa ae0152c28e use action from store instead of directly modifying state in components 2026-03-08 23:59:02 +09:00
pa b9c874bed0 refactor css 2026-03-08 23:58:59 +09:00
pa eeb5288027 use action from store instead of directly modifying state in components 2026-03-08 23:58:58 +09:00
pa 3d3ad27ca0 clean up sidebar css 2026-03-08 23:58:57 +09:00
pa 08033e99b6 refactor define css var 2026-03-08 23:58:56 +09:00
pa f9ab04ed17 container style 2026-03-08 23:58:53 +09:00
pa 729793dda2 improve image cropper 2026-03-08 23:58:53 +09:00
pa 97c79bef78 hover css 2026-03-08 23:58:52 +09:00
pa 6d0cfdd8aa adjust navmenu context menu and my avatars ui 2026-03-08 23:58:51 +09:00
Natsumi c0ce0ff1ea Add Steam shortcut tooltip 2026-03-08 23:58:50 +09:00
Natsumi 20ed194cb0 Add support for auto launching Steam app shortcuts 2026-03-08 23:58:48 +09:00
pa 4596ac4737 improve card ui 2026-03-08 01:26:46 +09:00
pa 6efa86f5a1 add context menu to favorite world avatar item 2026-03-08 01:26:45 +09:00
pa 746d94f226 improve my avatars and search 2026-03-08 01:26:44 +09:00
pa af006f2fde fix datatablelayout 2026-03-08 01:26:42 +09:00
pa 6f0d81814b improve favorites visibility ui 2026-03-08 01:26:42 +09:00
pa 2a861cb9b6 add mask as read context menu to noti center bell icon 2026-03-08 01:26:41 +09:00
pa 4b74e9df5a add mark as read to nav menu 2026-03-08 01:26:39 +09:00
pa be854bcd03 use config repo 2026-03-08 01:26:39 +09:00
pa 1dc00afe89 fead: add my avatar grid view 2026-03-08 01:26:38 +09:00
pa 029ed2b3e2 tweak my avavtar table ui 2026-03-08 01:26:37 +09:00
pa f862f8ad10 fix my avatar tab too width push the sidebar 2026-03-08 01:26:36 +09:00
pa baf50d8a62 Fix datatable header showing duplicate column names 2026-03-08 01:26:35 +09:00
pa 8f16685ffd fix make home not work 2026-03-08 01:26:35 +09:00
pa 395a47cbdc fix world dialog may sometimes not load the instance list 2026-03-08 01:26:30 +09:00
pa c342f40662 fix TanStack Query cache and cachedUsers not being cleared on logout 2026-03-07 18:41:51 +09:00
pa 6560bd36ac Add donation link 2026-03-07 18:41:51 +09:00
pa 26915b7003 fix statusbar 2026-03-07 18:41:49 +09:00
pa 1342c93d62 add context menu to friends locations card 2026-03-07 18:41:48 +09:00
pa 2370dff307 improve ui 2026-03-07 18:41:48 +09:00
pa e4f0abe74a feat: status bar 2026-03-07 18:41:47 +09:00
pa c42b126131 fix datatable header reorder issue 2026-03-07 18:41:45 +09:00
pa 9feef5d119 fix sidebar top toolbar ui issue 2026-03-07 18:41:44 +09:00
pa 2450971211 add context menu for groupsidebar 2026-03-07 18:41:44 +09:00
pa cf1577cb44 feat: unify table page size handling with useVrcxVueTable 2026-03-07 18:41:43 +09:00
pa 318f0b141c refactor utils 2026-03-07 18:41:43 +09:00
pa 8ddedb2d2d refactor store 2026-03-07 18:41:42 +09:00
pa e665b3815d add @tanstack/query 2026-03-07 18:41:41 +09:00
pa 7d2bb022a4 add progress bar to group member moderation ban import dialog 2026-03-07 18:41:39 +09:00
pa e997a7131f tweak vite config 2026-03-07 18:41:39 +09:00
pa 3f58a3c9dd feat: add friend sidebar context menu 2026-03-07 18:41:38 +09:00
pa 787f25705e add test 2026-03-07 18:41:37 +09:00
pa 761ef5ad6b audit fix 2026-03-07 18:41:37 +09:00
pa 75282fa5d2 fix some ui issue 2026-03-07 18:41:36 +09:00
pa fb6358b3be feat: add quick search 2026-03-07 18:41:32 +09:00
pa b570de6d4a fead: group member moderation ban export/import dialog (#1675) 2026-03-05 20:42:19 +09:00
Natsumi 0034f7847b Fix group member moderation 2026-03-05 20:42:18 +09:00
pa 3de0e30ad2 add test 2026-03-05 20:42:16 +09:00
pa 1be9d13cd4 feat: custom show/hide datatable col 2026-03-05 20:42:15 +09:00
pa 1decec4c69 feat: draggable datatable tablehead 2026-03-05 20:42:15 +09:00
pa 4df94478ba add test 2026-03-05 20:42:14 +09:00
pa db80d5e77d add eslint-plugin-jsdoc 2026-03-05 20:42:13 +09:00
pa decb96214a fix 2026-03-05 20:42:11 +09:00
pa 905999b9ae Refactor: Delegate notification store updates to calling components after API responses. 2026-03-05 20:42:10 +09:00
pa c522ab21f1 split function in the store 2026-03-05 20:42:09 +09:00
pa 4a72d77a96 split some function in the store 2026-03-05 20:42:09 +09:00
pa 1f1b996239 split some store func and add test 2026-03-05 20:42:08 +09:00
pa ea82825823 refactor: split common until and add ut 2026-03-05 20:42:07 +09:00
pa dd0293d2a6 add ut 2026-03-05 20:42:06 +09:00
pa 2946b58f47 feat: datatable sorting persist 2026-03-05 20:41:51 +09:00
pa cb1763160a fix close mian dialog container when delete avatar (#1669) 2026-03-03 23:44:31 +09:00
pa 7735eeeb08 style 2026-03-03 23:44:26 +09:00
pa 492ba14047 i18n 2026-03-03 23:44:22 +09:00
pa c68bbe9904 adjust favorites public ui 2026-03-03 23:44:17 +09:00
Celso Junior be647242ab Iu/add favorites type colors (#1645)
* fix: correct visibility dropdown keys and add en translations

* add pt translations

* add visibility color classes for avatar groups

* add visibility color classes for favorite groups

* Remove leftover commented import from my local testing

---------

Co-authored-by: pa <maplenagisa@gmail.com>
2026-03-02 14:43:30 +09:00
pa 0e809a0a23 change table limit text 2026-03-02 14:25:20 +09:00
pa 6893d578da fix 2026-03-02 14:25:19 +09:00
pa 4667f56b46 audit fix 2026-03-02 14:25:18 +09:00
pa 865ae0ab05 refactor custom nav dialog 2026-03-02 14:25:16 +09:00
Natsumi a9d465017b Debounce applyWorldDialogInstances 2026-03-02 14:25:15 +09:00
Natsumi c8e3dc8a6e Electron save zoom level 2026-03-02 14:25:15 +09:00
Natsumi 339b7d5ae9 Clean up status icon 2026-03-02 14:25:14 +09:00
pa 18e3f48329 add DeprecationAlert component 2026-03-02 14:25:13 +09:00
pa 05bebed2c1 feat: Add my avatars tab 2026-03-02 14:25:12 +09:00
pa fcf45178da Use shared rows per page in friends list 2026-03-02 14:25:10 +09:00
pa 8f60398cf5 improve filter ui logic 2026-03-02 14:25:09 +09:00
pa 7a8e8e4a73 add cropper for vrc plus management 2026-03-02 14:25:09 +09:00
pa cc696701b5 add cropper for change avatar/world image dialog 2026-03-02 14:25:02 +09:00
Natsumi 93c34209b4 Fix friends list table size 2026-02-28 17:18:11 +11:00
Natsumi 5565cfc6f6 Fix auto login 2026-02-28 17:18:10 +11:00
pa ab0783f64f auto switch corresponding tab when the notification center is opened 2026-02-28 17:18:10 +11:00
pa e0f7b733af add tooltip 2026-02-28 17:18:10 +11:00
pa e438968bc1 improve feed filter selection logic 2026-02-28 17:18:09 +11:00
pa a00d834ff3 bump and audit fix 2026-02-28 17:18:09 +11:00
pa 1f253fafaf add image fallback 2026-02-28 17:18:09 +11:00
pa 69cd330257 improve ui 2026-02-28 17:18:09 +11:00
pa a814fe95aa add favorites group for friends locations 2026-02-28 17:18:08 +11:00
pa 503a7978f5 Allow sidebar use panel collapse/expand instead of conditional rendering 2026-02-28 17:18:08 +11:00
pa 8d80ef43c6 fix: reset attemptingAutoLogin flag after auto-login attempt 2026-02-28 17:18:08 +11:00
pa ea9d75f8ab fix friends in unselected groups not appear in the online list 2026-02-28 17:18:08 +11:00
Natsumi 9a789d514d Fix unfriend date on user dialog 2026-02-28 17:18:07 +11:00
pa 10f14e1081 add right-click context menu to current user item 2026-02-28 17:18:07 +11:00
pa 31bb8be576 ui adjust 2026-02-28 17:18:07 +11:00
pa 60fc08b472 Allow users to reorder favorite friend groups in the sidebar 2026-02-28 17:18:06 +11:00
pa 304413c1e3 bio dialog autosize 2026-02-28 17:18:06 +11:00
pa b81c353a51 fix notification center title 2026-02-28 17:18:06 +11:00
pa abc82e6988 Mark as seen (queued to avoid 429 rate-limiting) 2026-02-28 17:18:05 +11:00
Misaka_L 033b53535e fix: overlay function queue keep growing cause memory leak in electron (#1659) 2026-02-28 17:17:18 +11:00
Natsumi 6babfb31fe Fix seenNotificationV2 2026-02-23 21:29:31 +11:00
pa 167818556a feat: add functionality to exclude specific friends from the mutual friends graph 2026-02-23 21:29:31 +11:00
pa b8602bfb7b Add community separation setting and reset button to the mutual friends graph layout 2026-02-23 21:29:30 +11:00
pa 49d8f1c60b migrate error notifications from Noty to a toast 2026-02-23 21:29:30 +11:00
pa b5b5776275 Disable the blur effect on dialog overlays when GPU acceleration is disabled 2026-02-23 21:29:30 +11:00
pa 8296d31e67 refine notification filtering to exclude explicitly unseen notifications 2026-02-23 21:29:29 +11:00
Natsumi 581933f873 Fix unfriended date 2026-02-23 21:29:29 +11:00
pa cbe6b73d0b add distinct status dot styles for active, join, ask, busy, and offline friend statuses 2026-02-23 21:29:29 +11:00
pa d9f88fe987 red dot 2026-02-23 21:29:28 +11:00
Natsumi bd8551461b Reverse fav export order 2026-02-23 21:29:28 +11:00
osiris-plus 612ea945b4 Fixed overflowing text in a cell drawing over the adjacent cell (#1654) 2026-02-21 21:22:32 +09:00
pa 2839710b09 text 2026-02-21 21:20:08 +09:00
pa 596a4149f8 red dot and i18n 2026-02-21 21:20:07 +09:00
pa 83cbbf681d onBeforeUnmount 2026-02-21 21:20:06 +09:00
pa b9db931017 add tanstack/vue-virtual 2026-02-21 21:20:05 +09:00
pa dd631ac318 add hover card 2026-02-21 21:20:03 +09:00
pa f5486262d4 add location 2026-02-21 21:20:02 +09:00
pa 94c33f90ae fix 2026-02-21 21:20:02 +09:00
pa e2f6fbfc85 Display only unseen notifications in the notification center and remove the past notifications section from the list 2026-02-21 21:20:01 +09:00
Natsumi 472508248e Fix feed public/private search 2026-02-21 21:19:47 +09:00
Natsumi 92c9488298 Fix open notification link 2026-02-21 16:31:43 +11:00
Natsumi c530405bf7 NotificationV2 replace symbols 2026-02-21 16:24:10 +11:00
Natsumi 623a5bda77 Fix ja locale 2026-02-20 23:46:46 +11:00
Misaka_L a5222b9e7d fix: edit-note-and-memo-dialog overflow (#1652) 2026-02-20 23:45:38 +11:00
Misaka_L 7b8bd84d37 fix: send boop dialog overflow (#1653) 2026-02-20 23:45:31 +11:00
neco222 805c3edbbc Correct the incorrect translation - Update ja.json (#1642)
* Update ja.json

* Update ja.json

* Update ja.json
2026-02-20 23:45:24 +11:00
XoFKon 7726a6356b Update Traditional Chinese (#1650) 2026-02-20 23:45:13 +11:00
Natsumi 5fe2f8ddf5 notifications v2 table 2026-02-20 23:43:54 +11:00
pa aa6ae21033 reddot 2026-02-20 23:43:53 +11:00
Natsumi 33e3ba0fb3 Keep instance when reopening world dialog 2026-02-20 23:43:53 +11:00
pa 1594103f39 Badge 2026-02-20 23:43:53 +11:00
pa 82698572f0 some fix 2026-02-20 23:43:52 +11:00
pa 9a683a587b revert 2026-02-20 23:43:52 +11:00
pa 1e7857deac login settings dialog 2026-02-20 23:43:52 +11:00
Natsumi 2b4d04a09a tooltip 2026-02-18 18:58:25 +09:00
pa 7288995c73 i18n 2026-02-18 18:58:24 +09:00
pa a13b197d06 fix i18n 2026-02-18 18:58:23 +09:00
Natsumi e7114fa1b6 Avatar name and bio link length 2026-02-18 18:58:23 +09:00
Natsumi 4db9cd0392 Local friend import/export 2026-02-18 18:58:22 +09:00
pa ec6d224d71 feat add notification center 2026-02-18 18:58:21 +09:00
Natsumi 5d36163eef Fix avatar time spent and reload dialog when opening same dialog when no dialog is open 2026-02-18 18:58:20 +09:00
pa 9b313e04ba tidy settings 2026-02-18 18:58:19 +09:00
pa 0d47e33ba1 style and i18n adjust 2026-02-18 18:58:18 +09:00
pa afbcf0b84b Renamed "VRCX Favorite Friends" → "Favorite Groups for Filtering" 2026-02-18 18:58:17 +09:00
pa 6ed1ef565b fix 2026-02-18 18:58:17 +09:00
pa 0a16b1a4e2 add allFavoriteOnlineFriends 2026-02-18 18:58:16 +09:00
pa 2e627ba6f5 Relocate sidebar settings from the Appearance tab to a new popover within the Sidebar 2026-02-18 18:58:15 +09:00
pa ad3346427f Enable adding friends to multiple local favorite groups and update instance activity charts to include all favorite friends. 2026-02-18 18:58:15 +09:00
pa 4e552bf3b9 refactor auto change status 2026-02-18 18:58:14 +09:00
pa 64869a218e conditionally display 'favorite-worlds' tab in UserDialog for other users 2026-02-18 18:58:13 +09:00
Natsumi 1934aee9e0 Language tooltip 2026-02-18 18:58:12 +09:00
Natsumi cd1aba59a1 Fix updating instance player count when reopening same user dialog 2026-02-18 18:58:11 +09:00
pa 6b78bebae6 Add language selection dropdown to the login view 2026-02-18 18:58:10 +09:00
pa e643b6b5ad add OtpDialogModal 2026-02-18 18:58:09 +09:00
Natsumi c93b3fbf9f Fix bulk unfriend count 2026-02-18 18:58:08 +09:00
pa 2e628cdfe1 adjust tools icon 2026-02-18 18:58:07 +09:00
pa 5c10a4e83b remove drag-and-drop prevention attributes from main app div 2026-02-18 18:58:07 +09:00
pa c55f81694d add mutual friend go to friend 2026-02-18 18:58:06 +09:00
pa 1c79b7a049 add unit test and clean up 2026-02-18 18:58:06 +09:00
pa fbeb02fb7d add unit test 2026-02-18 18:58:05 +09:00
pa b12c3d679b add test for Location component 2026-02-18 18:58:04 +09:00
pa 6c6f2211cd add unit test 2026-02-18 18:58:03 +09:00
pa 4039698c71 Remove circular dependencies between utility functions 2026-02-18 18:58:02 +09:00
pa 5725255e4b fix viteset 2026-02-18 18:58:00 +09:00
pa 30ecb00063 refactor auto change status dialog ui style 2026-02-18 18:58:00 +09:00
pa 8a4cc88e39 add auto change status description 2026-02-18 18:57:59 +09:00
pa 2d5a8bae7d feat: add feed range date search 2026-02-18 18:57:58 +09:00
pa d423406a28 add range calendar component 2026-02-18 18:57:57 +09:00
pa 77f1795697 fix avatarinfo cannot refresh 2026-02-18 18:57:56 +09:00
Natsumi 689549ecab Previous instances dialog height 2026-02-18 18:57:55 +09:00
pa 5ad4713373 use ref to replace reactive 2026-02-18 18:57:55 +09:00
pa 10e8008185 update package.json engines field 2026-02-18 18:57:54 +09:00
pa ace9845522 add package.json engines field 2026-02-18 18:57:53 +09:00
Natsumi 84f7103fd6 revert: remove unnecessary getUser 2026-02-18 18:57:52 +09:00
pa 2964a2afcd fix lost i18n keys 2026-02-18 18:57:39 +09:00
pa 39c8072ea1 bump 2026-02-11 23:31:44 +09:00
pa f83d23d34d use vitest 2026-02-11 23:31:38 +09:00
pa d8385ba89f fix local friend favorites 2026-02-11 23:31:34 +09:00
pa 1ef618f358 remove unnecessary getUser 2026-02-11 23:31:30 +09:00
pa b0bc6dd03c refactor friendsidebar same instance logic 2026-02-11 23:31:26 +09:00
pa 61a4176f47 add local favorites friend 2026-02-11 23:31:22 +09:00
pa 6d76140e1d reorder i18n keys 2026-02-11 23:30:50 +09:00
pa 3f2ab3ff3c remove unused i18n keys 2026-02-11 23:30:44 +09:00
Natsumi 58d7c79541 v2026.02.11 2026-02-11 17:53:22 +13:00
Natsumi 895bf0f713 Revert: macOS auto cask bump 1 2026-02-11 17:52:53 +13:00
Natsumi 93e305620d Fix dialog loading state 2026-02-11 17:49:21 +13:00
Natsumi 684298b026 Revert: macOS auto cask bump 2026-02-11 17:24:21 +13:00
Yuki b2b489e88f Update Japanese Translation (#1638) 2026-02-11 12:42:03 +09:00
Natsumi e3d96c88b0 Fix cask bump 2026-02-11 12:40:37 +09:00
Natsumi 2b0d2847f5 macOS auto cask bump 2026-02-11 12:40:03 +09:00
Asty 20b725f706 Rename wlx-overlay to wayvr (#1635) 2026-02-11 11:52:21 +13:00
flower_elf 7019cc46b1 Update Chinese Simplified localization (#1628)
* chore(i18n): Update Chinese Simplified localization

* add graph layout configuration in zh-CN localization

* update invite response messages and add model fetching prompts

Co-Authored-By: 川澄 雪 <KawasumiSena@Gmail.com>

* for clarity

* update model name description

* Update zh-CN.json

* option to show Discord connections in mutual friends settings

* Update zh-CN.json

* update translator list and improve mutual friend graph settings translations

Co-Authored-By: Antenna #3 <50518073+Anteness@users.noreply.github.com>

* Update translation for mutural network gragh in line 413 of zh-CN.json

* fix: update table settings translations for clarity

* for consistency

* fix typo

* del useless key

* fix: update disclaimer for clarity and responsibility

---------

Co-authored-by: 川澄 雪 <KawasumiSena@Gmail.com>
Co-authored-by: Antenna #3 <50518073+Anteness@users.noreply.github.com>
Co-authored-by: Anteness <“vv03a24@foxmail.com>
2026-02-10 21:20:20 +09:00
pa 002990fbbc fix: refine platform column layout (#1604) 2026-02-10 20:16:01 +09:00
Natsumi ac74a1a360 Update users in instances when reopening same dialogs 2026-02-10 22:02:30 +13:00
Natsumi a565772ec9 Fix edit and send invite message dialog closing 2026-02-10 19:34:29 +13:00
Natsumi cb99d03f98 Discord profile badge 2026-02-10 19:11:24 +13:00
Natsumi 2265def591 Notification type fallback 2026-02-10 19:11:24 +13:00
Natsumi 927e564a30 Show Discord Connections toggle 2026-02-10 19:11:23 +13:00
Natsumi 37c41a5311 Group moderation table sorting 2026-02-10 19:10:53 +13:00
Natsumi 2406486850 Add group moderation log sorting, fix private to bottom 2026-02-10 19:10:52 +13:00
pa 4bf08bb17e i18n: ja 2026-02-09 23:18:50 +09:00
Natsumi 08ed9a25bc Fixes 2026-02-09 23:18:44 +09:00
Natsumi a204006113 Disable updater on macOS 2026-02-09 23:18:40 +09:00
pa cc2c45847f disable autoResetPageIndex 2026-02-09 23:18:36 +09:00
pa a31f98a4a2 use immutable array operations for game log table data to ensure reactivity 2026-02-09 23:18:31 +09:00
pa 71e2d99711 fix gamelog rowid 2026-02-09 23:18:27 +09:00
pa bd611cbcfa adjust Favorites item name styles 2026-02-09 23:18:23 +09:00
pa 1751929f87 fix feed collapsing due to unstable row IDs 2026-02-09 23:18:16 +09:00
pa 980b2b7e12 fix sonner theme switching for toasts 2026-02-08 16:02:05 +09:00
pa ccfd4a3dd7 revert sidebar status label change 2026-02-08 16:01:57 +09:00
Natsumi 9f19a65fc6 Group event button hover 2026-02-08 16:01:52 +09:00
Natsumi 7af8b19a72 tsconfig 2026-02-08 16:01:48 +09:00
Natsumi 752c75b37c Fix updated at dates 2026-02-08 16:01:44 +09:00
pa d3e44523bd fix mutuals chart by filtering out invalid user IDs 2026-02-08 16:01:40 +09:00
pa cb0c241580 move Auto Change Status settings to a dialog in Tools view 2026-02-08 16:01:35 +09:00
pa 2d4d6816d3 style 2026-02-08 16:01:30 +09:00
Natsumi 1959497071 Direct access from clipboard toast 2026-02-08 16:01:26 +09:00
Natsumi ecce12a9fc Last updated dropdown 2026-02-08 16:01:22 +09:00
Natsumi 33c8d97403 Table margins 2026-02-08 16:01:18 +09:00
Natsumi b4bf4e2567 Fix favs scrolling 2026-02-08 16:01:14 +09:00
Natsumi 66a5a1ff15 Fix previous instance page index and sort order 2026-02-08 16:01:11 +09:00
pa 37dda6962d add settings panel for mutual friends graph layout options 2026-02-08 16:01:07 +09:00
pa 236e2e85de fix mutual friends fetch and render logic 2026-02-08 16:01:02 +09:00
pa f87dde04f8 improve Breadcrumb navigation with back button and tooltips 2026-02-08 16:00:58 +09:00
pa 20457ff082 fix 2026-02-08 16:00:50 +09:00
Natsumi 8decb568fe Avatar tags grey 2026-02-03 15:42:30 +13:00
Natsumi 38cad7d2e3 Fix wrist overlay grey 2026-02-03 15:42:30 +13:00
Natsumi 50a037686b Fix jumpDialogCrumb 2026-02-03 15:42:30 +13:00
Natsumi 5a27e6fb51 Avatar lookup loading toast 2026-02-03 15:42:29 +13:00
pa bbbb79eaca split charts into separate routes (#1605) 2026-02-03 00:14:28 +09:00
Natsumi 1cbafbeaeb Fix macOS titlebar 2026-02-03 00:14:23 +09:00
pa 29a7d7c9c6 rewrite mutual friends graph 2026-02-03 00:14:17 +09:00
pa a0da1bb3d5 fix 2026-02-02 20:56:36 +09:00
laomo c9e7dd24a4 OpenAI api add Fetch Models (#1625)
* 1.Added a Fetch Models button for OpenAI-compatible providers to load available models from custom endpoints and select them from a dropdown.

2.Added world description translation support .

* fix
2026-02-02 20:56:07 +09:00
pa 918d1f0960 fix wrist background style binding 2026-02-02 20:55:25 +09:00
Natsumi bd18cd5e72 Cef v144.0.120 2026-02-02 12:10:05 +13:00
pa d4eacff506 init sigma.js 2026-02-02 12:09:24 +13:00
pa 84502fc4af frienditem hover effect 2026-02-02 12:09:24 +13:00
Eeacks 50eb33bbd8 fix typo (#1620) 2026-02-02 12:08:49 +13:00
monolithic827 8ef9b3a89f Fix attribution header for OpenAI translation (#1623)
* Fix attribution header when using the OpenAI bio translation API

* Add an app name for attribution

* Add back the original referer header
2026-02-02 12:08:35 +13:00
Luna 5993978f33 make avatar thumbnail display consistent with other images (#1619) 2026-02-02 12:08:29 +13:00
pa 2dab3733fd add auto-login delay setting and prompt 2026-02-01 23:33:49 +09:00
pa 7a0b0b8bd4 add avatar search input in user dialog 2026-02-01 23:33:41 +09:00
pa b5442934b9 improve getGameLogByLocation sql 2026-02-01 23:33:32 +09:00
pa e40746dee4 improve getFeedByInstanceId sql 2026-02-01 23:33:28 +09:00
pa a33faf6b1a use reka splitter config api 2026-02-01 23:33:23 +09:00
pa 564f5bc73c try fix sidebar resizing issue 2026-02-01 23:33:18 +09:00
pa 0b59423f61 add searchTableSize limit 2026-02-01 23:33:13 +09:00
pa d9080205e5 remove language asset hash from filename 2026-02-01 23:33:09 +09:00
pa 91692229ae simplify main layout resizing 2026-02-01 23:33:05 +09:00
pa 858fb076da clear friend log table when navigating away from it 2026-02-01 23:33:00 +09:00
monolithic827 d838c4a9e5 Fix button spacing for some dialog boxes (#1617)
* fix location text truncation and overflow issues (#1606)

* Fix button spacing for dialog boxes

---------

Co-authored-by: pa <maplenagisa@gmail.com>
2026-01-31 23:50:02 +09:00
Natsumi c86cf5e5ed Open last dialog on error 2026-01-31 23:47:57 +09:00
pa 8f57fbb572 fix feed search sql 2026-01-31 23:47:51 +09:00
pa 1c587e17d2 fix :Primary Password Masking no longer present (#1614) 2026-01-31 23:47:47 +09:00
Natsumi 14558238c5 Fix friended tooltip 2026-01-31 23:47:40 +09:00
Natsumi f7ebbe2135 Fix user dialog dot indicator 2026-01-31 23:47:36 +09:00
Natsumi 1fbb19d50b Fix feed bug 2026-01-31 23:47:32 +09:00
Natsumi a5ea69ba22 Fix dialog jumping around on image load and fix showing prev image 2026-01-31 23:47:25 +09:00
Natsumi 7c24e2038d Grey title bar for grey theme 2026-01-31 23:47:19 +09:00
Natsumi 916a0f94db Fix playerList show world 2026-01-31 23:47:14 +09:00
Natsumi bf687a2405 Retain dialog data when reopening same dialog 2026-01-31 23:47:10 +09:00
pa 5ff078e351 Supplementary disclaimer2 2026-01-31 23:47:04 +09:00
Natsumi 1b29ade8d3 getFriendLogHistoryForUserId 2026-01-31 23:47:00 +09:00
pa 48d84363ec add isFriendLogLoaded flag to Friend store 2026-01-31 23:46:55 +09:00
pa 1d4026a89c friendlog gamelog query data when navigating to pages 2026-01-31 23:46:52 +09:00
pa 48c1dd98fa improve gamelog sql 2026-01-31 23:46:46 +09:00
pa 949c64d17b fix location text truncation and overflow issues (#1606) 2026-01-31 23:46:42 +09:00
pa d0f6ab6574 improve lookup feed table sql 2026-01-31 23:46:37 +09:00
pa e35e190ba1 hide data table empty state when loading 2026-01-31 23:46:33 +09:00
pa cbd41598b5 improve search feed table sql query 2026-01-31 23:46:28 +09:00
pa a6092efd94 improve lookup feed database sql query 2026-01-31 23:46:24 +09:00
pa b4839c8ed5 refactor usevuetable data 2026-01-31 23:46:19 +09:00
pa 2952f4d415 use worker-timers 2026-01-31 23:46:14 +09:00
pa 1bccbb30a4 improve gamelog table performance 2026-01-31 23:46:09 +09:00
pa e161994783 improve feed performance 2026-01-31 23:46:04 +09:00
pa 0a1b0162c6 fix notification dot position 2026-01-31 23:45:58 +09:00
Yuki 7f8972e71e Update Japanese Translation (#1603) 2026-01-28 20:05:09 +09:00
Natsumi 131358cac7 v2026.01.28 2026-01-28 17:53:26 +13:00
pa 982689564f fix cannot save aside panel size after dragging 2026-01-28 17:52:55 +13:00
Natsumi 6ff2058230 Updating funding 2 2026-01-27 04:44:34 +13:00
Natsumi 6d471c0e3f Updating funding 1 2026-01-27 04:44:07 +13:00
Natsumi a69945c119 Updating funding 2026-01-27 04:42:35 +13:00
pa 0197e6ecd8 fix markdown styles 2026-01-26 21:15:03 +09:00
flower_elf cf7b814ad6 Update Chinese Simplified localization (#1577)
* chore(i18n): Update Chinese Simplified localization

* Update zh-CN.json

* Update zh-CN.json

* update self-invite translation for clarity

* Update zh-CN.json

* Finish work

* some fix

* update translation for clarity

* Update zh-CN.json

* Add Trust-level translation

* Update zh-CN.json

* impove translation

Co-Authored-By: 川澄 雪 <KawasumiSena@Gmail.com>

* fix something

* Update zh-CN.json

* impove translation

* fix typo

* update font tooltip

* impove translation

Co-Authored-By: Map1en <map1en@linux.com>

* Update zh-CN.json

---------

Co-authored-by: 川澄 雪 <KawasumiSena@Gmail.com>
Co-authored-by: Map1en <map1en@linux.com>
2026-01-26 21:47:50 +13:00
Yuki 05ba36736c Update Japanese Translation (#1587) 2026-01-26 21:47:22 +13:00
WLK 2e3a3e7240 fix: AES key generation out of bounds (#1596) 2026-01-26 21:46:22 +13:00
Natsumi 397dacc51a Remove auto launch soft close 2026-01-26 21:41:27 +13:00
pa 7a4014f846 fix ToggleGroup emitting empty values 2026-01-26 21:41:27 +13:00
pa 0a3597f84e fix 404 handling in main dialog 2026-01-26 21:41:26 +13:00
monolithic827 27f913552e Remove outline from delete buttons in avatar database provider UI (#1598)
* Remove the outline from delete provider button

* Change button to icon to avoid hover background

* Tweak default opacity
2026-01-26 14:41:34 +09:00
pa dbc98cdc4e fix @tanstack/virtual not fully support smooth scroll behavior on dynamic height containers 2026-01-25 22:56:15 +09:00
pa 722cc615cb Remove the ScrollArea from the sidebar 2026-01-25 22:56:09 +09:00
pa 7e312a0e8c add BackToTopVirtual component 2026-01-25 22:56:06 +09:00
pa ddd191e332 Prevent the icon from being squished 2026-01-25 22:55:59 +09:00
pa 9cecb6a45a fix friend sidebar auto scroll issue 2026-01-25 22:55:54 +09:00
pa 9ce33e337f remove useVirtualizerAnchor 2026-01-25 22:55:50 +09:00
pa 2b1d0ff344 remove unnecessary props and event emissions in Sidebar components 2026-01-25 22:55:45 +09:00
pa bcee6b5298 fix alert dialog description wrapping issue 2026-01-25 22:55:35 +09:00
SymphonyVR b3b655d8ac fix(memory leak): implement self-destruct timeout for notyMap (#1594)
* fix(notification): implement self-destruct timeout for notyMap to prevent memory leak

* fix

---------

Co-authored-by: Map1en <maplenagisa@gmail.com>
2026-01-25 01:39:25 +09:00
pa b2cd8a1d68 small fix 2026-01-25 01:23:29 +09:00
Natsumi f840dabe43 Clean up treeData 2026-01-25 01:23:23 +09:00
pa e22f214210 fix vr hmd positioning of notifications 2026-01-24 19:13:00 +09:00
pa 642d222faa fix vue json pretty collapse issue 2026-01-24 19:12:52 +09:00
Natsumi 0af08e7741 Faster dialog switch 2026-01-23 22:33:36 +09:00
pa fb9ec31c93 bump 2026-01-23 22:33:32 +09:00
pa 0b067cad89 adjust resize cursor style 2026-01-23 22:33:28 +09:00
pa 5846fb7adb improve i18n 2026-01-23 22:33:24 +09:00
pa c30d7265ff improve theme color selection ux 2026-01-23 22:33:18 +09:00
pa 694183fb41 fix main dialog flickr issue 2026-01-23 22:33:14 +09:00
Natsumi 4a10ab70e8 Test notification 2026-01-23 22:33:10 +09:00
Natsumi dfce6760ca Fixes 2026-01-23 22:33:05 +09:00
pa c4f75e50d7 tidy up 2026-01-23 22:33:00 +09:00
pa 739418733d tidy up 2026-01-23 22:32:53 +09:00
pa 4e5acb990f move some shortcuts to tools 2026-01-23 00:27:59 +09:00
pa fab14abdd6 fix styles 2026-01-22 22:33:07 +09:00
pa 290679fb24 fix main dialog container navigation 2026-01-22 22:32:53 +09:00
pa 24d45d5967 improve color picker UI 2026-01-22 22:32:47 +09:00
Natsumi 0a0af0db75 Remove saved account on incorrect password 2026-01-22 22:32:42 +09:00
Natsumi 3b38a4ae61 Align group instances 2026-01-22 22:32:36 +09:00
Natsumi bc91653d38 previousInstancesInfoDialog cleanup 2026-01-22 22:32:29 +09:00
pa 2b739fd2b6 remove log 2026-01-22 22:32:22 +09:00
pa f0b7d74555 improve i18n and fix tooltip focus behavior 2026-01-22 21:24:37 +09:00
pa ba7ffa5497 merge previous instances dialog 2026-01-22 21:24:31 +09:00
pa 3c37071011 add empty component and poilsh styles 2026-01-22 21:24:26 +09:00
pa 1514012c4c fix navmenu red indicator 2026-01-22 21:24:22 +09:00
pa 3a05f69ad5 fix main dialog width 2026-01-22 21:24:16 +09:00
pa 9bf26184ac remember isOfflineFriends collapsed 2026-01-22 21:24:11 +09:00
pa ab4dde0836 keep alive prev instance dialog 2026-01-22 21:24:01 +09:00
Natsumi a1f4a22609 Fix opening last active tab 2026-01-22 19:17:37 +13:00
Natsumi 98fbadae2f Fix lastVisit and visitCount 2026-01-22 19:17:37 +13:00
pa 60b49c71e1 change previous instance dialog navigation to use main dialog container 2026-01-22 19:17:36 +13:00
Natsumi 91deb37c62 Fix notification delete button 2026-01-22 19:17:36 +13:00
Natsumi dbbaf7732f Fix file analysis object type 2026-01-22 19:17:36 +13:00
Natsumi 2cfa833e6b Move Electron userdata folder, use XDG_CONFIG_HOME & XDG_CACHE_HOME 2026-01-22 19:17:35 +13:00
Natsumi ac87cf9a90 Tooltip text overflow and new lines 2026-01-22 19:17:35 +13:00
pa 81acfa8734 fix sidebar auto scroll on list update 2026-01-22 19:17:35 +13:00
pa f8daa6ff4c add cleanInstanceCache 2026-01-22 19:17:34 +13:00
pa ecbb0612ec improve DataTableLayout scrolling behavior 2026-01-22 19:17:34 +13:00
pa ded6b0ccf0 add tooltip to notification 2026-01-22 19:17:34 +13:00
pa 0fa6e48fd7 fix 2026-01-22 19:17:34 +13:00
pa 6dfea34dd2 fix avatar dialog layout 2026-01-22 19:17:33 +13:00
pa b2bd7693bb feat: add breadcrumb components and main dialog layout functionality 2026-01-22 19:17:33 +13:00
Natsumi 0b636df330 Fix closing PreviousInstancesGroupDialog and instance layout 2026-01-22 19:17:33 +13:00
pa 954735928c user dropdown destructive button color fix 2026-01-22 19:17:32 +13:00
pa f2a68fbbdf use official global style 2026-01-22 19:17:32 +13:00
pa 56a8713374 improve calendar style 2026-01-22 19:17:32 +13:00
Natsumi d9bc93640d usercutout align 2026-01-22 19:17:31 +13:00
Natsumi 62e21d54fb Always run overlay when running VRCX in debug mode 2026-01-22 19:17:31 +13:00
Natsumi cf43938fd3 Fix bio diff and fullscreen image click to close 2026-01-22 19:17:31 +13:00
pa a07ae7941f fix reddot 2026-01-22 19:17:31 +13:00
pa da9cb3dab6 add some table loading spinner 2026-01-22 19:17:30 +13:00
pa 39e9631812 fIx toaster style 2026-01-22 19:17:30 +13:00
pa 2d3cd9a3b3 sidebar virtual dom and textfield row sizing 2026-01-22 19:17:30 +13:00
pa 1e25255ac5 improve i18n 2026-01-19 11:50:25 +09:00
pa 7303cd0b33 fix 2026-01-19 11:50:16 +09:00
kubectl cacbf742d1 feat: add a setting to enable pointer on hover (#1585)
* feat: added striped table mode for visual clarity

also added a settings toggle to revert to original behavior

* fix: add pointer on hover behind a toggle

* fix: add `x-link` class to the selector

* fix: indicate that this is global forceful overide
2026-01-19 09:33:01 +09:00
Natsumi 29b83c5b89 Formatting fixes 2026-01-19 09:28:39 +09:00
864 changed files with 124498 additions and 58821 deletions
+40
View File
@@ -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
View File
@@ -1,3 +1,2 @@
github: [natsumi-sama] ko_fi: map1en_
patreon: Natsumi_VRCX patreon: Natsumi_VRCX
ko_fi: natsumi_sama
+23
View File
@@ -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.
+4
View File
@@ -10,3 +10,7 @@ Installer/version_define.nsh
bun.lock bun.lock
.env.sentry-build-plugin .env.sentry-build-plugin
AGENTS.md
AI_GUIDE.md
CLAUDE.md
coverage/
+2
View File
@@ -0,0 +1,2 @@
engine-strict=true
fund=false
+2 -9
View File
@@ -1,22 +1,15 @@
{ {
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80, "printWidth": 80,
"tabWidth": 4, "tabWidth": 4,
"semi": true, "semi": true,
"singleQuote": true, "singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "none", "trailingComma": "none",
"bracketSpacing": true, "bracketSpacing": true,
"arrowParens": "always", "arrowParens": "always",
"endOfLine": "auto",
"overrides": [ "overrides": [
{ {
"files": "*.js", "files": ["*.vue"],
"options": {
"parser": "meriyah"
}
},
{
"files": "*.vue",
"options": { "options": {
"printWidth": 120, "printWidth": 120,
"bracketSameLine": true, "bracketSameLine": true,
+1664
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -3,8 +3,7 @@
"vue.volar", "vue.volar",
"lokalise.i18n-ally", "lokalise.i18n-ally",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode", "oxc.oxc-vscode",
"lllllllqw.jsdoc",
"bradlc.vscode-tailwindcss" "bradlc.vscode-tailwindcss"
] ]
} }
+2 -1
View File
@@ -3,7 +3,8 @@
"i18n-ally.keystyle": "nested", "i18n-ally.keystyle": "nested",
"i18n-ally.sourceLanguage": "en", "i18n-ally.sourceLanguage": "en",
"i18n-ally.indent": 4, "i18n-ally.indent": 4,
"editor.defaultFormatter": "esbenp.prettier-vscode", "oxc.fmt.configPath": ".oxfmtrc.json",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"omnisharp.enableRoslynAnalyzers": true, "omnisharp.enableRoslynAnalyzers": true,
"omnisharp.useModernNet": false, "omnisharp.useModernNet": false,
+12
View File
@@ -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() public string GetLaunchCommand()
{ {
var command = StartupArgs.LaunchArguments.LaunchCommand; var command = StartupArgs.LaunchArguments.LaunchCommand;
+212 -16
View File
@@ -4,8 +4,11 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Timers; using System.Timers;
using Microsoft.Win32;
using NLog; using NLog;
namespace VRCX namespace VRCX
@@ -28,10 +31,11 @@ namespace VRCX
public readonly string AppShortcutVR; public readonly string AppShortcutVR;
private DateTime startTime = DateTime.Now; 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 readonly Timer childUpdateTimer;
private int timerTicks = 0; private int timerTicks = 0;
private static readonly byte[] shortcutSignatureBytes = { 0x4C, 0x00, 0x00, 0x00 }; // signature for ShellLinkHeader 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; private const uint TH32CS_SNAPPROCESS = 2;
@@ -102,11 +106,13 @@ namespace VRCX
} }
} }
[SupportedOSPlatform("windows")]
private void OnProcessStarted(MonitoredProcess monitoredProcess) private void OnProcessStarted(MonitoredProcess monitoredProcess)
{ {
if (!Enabled || !monitoredProcess.HasName(VRChatProcessName) || monitoredProcess.Process.StartTime < startTime) if (!Enabled || !monitoredProcess.HasName(VRChatProcessName) || monitoredProcess.Process.StartTime < startTime)
return; return;
// Start auto start processes
lock (startedProcesses) lock (startedProcesses)
{ {
if (KillChildrenOnExit) if (KillChildrenOnExit)
@@ -114,8 +120,10 @@ namespace VRCX
else else
UpdateChildProcesses(); UpdateChildProcesses();
var shortcutFiles = FindShortcutFiles(AppShortcutDirectory); var (shortcutFiles, steamIds) = FindShortcutFiles(AppShortcutDirectory);
shortcutFiles.AddRange(FindShortcutFiles(Program.AppApiInstance.IsSteamVRRunning() ? AppShortcutVR : AppShortcutDesktop)); var (platformShortcutFiles, platformSteamIds) = FindShortcutFiles(Program.AppApiInstance.IsSteamVRRunning() ? AppShortcutVR : AppShortcutDesktop);
shortcutFiles.AddRange(platformShortcutFiles);
steamIds.AddRange(platformSteamIds);
foreach (var file in shortcutFiles) foreach (var file in shortcutFiles)
{ {
if (RunProcessOnce && IsProcessRunning(file)) if (RunProcessOnce && IsProcessRunning(file))
@@ -126,8 +134,12 @@ namespace VRCX
StartChildProcess(file); StartChildProcess(file);
} }
foreach (var steamId in steamIds)
{
StartSteamGame(steamId);
}
if (shortcutFiles.Count == 0) if (shortcutFiles.Count == 0 && steamIds.Count == 0)
return; return;
timerTicks = 0; timerTicks = 0;
@@ -143,6 +155,7 @@ namespace VRCX
{ {
UpdateChildProcesses(); // Ensure the list contains all current child processes. UpdateChildProcesses(); // Ensure the list contains all current child processes.
// Stop auto start processes
Parallel.ForEach(startedProcesses.ToArray(), pair => Parallel.ForEach(startedProcesses.ToArray(), pair =>
{ {
var processes = pair.Value; var processes = pair.Value;
@@ -216,11 +229,12 @@ namespace VRCX
if (proc.HasExited) if (proc.HasExited)
continue; continue;
if (proc.CloseMainWindow()) // breaks some apps
continue; // if (proc.CloseMainWindow())
// continue;
if (proc.WaitForExit(1000)) //
continue; // if (proc.WaitForExit(1000))
// continue;
proc.Kill(); proc.Kill();
} }
@@ -345,23 +359,45 @@ namespace VRCX
/// </summary> /// </summary>
/// <param name="folderPath">The folder path.</param> /// <param name="folderPath">The folder path.</param>
/// <returns>An array of shortcut paths. If none, then empty.</returns> /// <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); var directoryInfo = new DirectoryInfo(folderPath);
FileInfo[] files = directoryInfo.GetFiles(); var files = directoryInfo.GetFiles();
List<string> ret = new List<string>(); var shortcuts = new List<string>();
var steamIds = new List<string>();
foreach (FileInfo file in files) foreach (var file in files)
{ {
if (IsShortcutFile(file.FullName)) 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> /// <summary>
/// Determines whether the specified file path is a shortcut by checking the file header. /// Determines whether the specified file path is a shortcut by checking the file header.
/// </summary> /// </summary>
@@ -377,5 +413,165 @@ namespace VRCX
return headerBytes.SequenceEqual(shortcutSignatureBytes); 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;
}
} }
} }
+16 -10
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Windows.Forms; using System.Windows.Forms;
@@ -24,15 +24,19 @@ namespace VRCX
/// <summary> /// <summary>
/// Private holder of current theme /// Private holder of current theme
/// </summary> /// </summary>
private static int currentTheme; private static int currentTheme = -1;
/// <summary> /// <summary>
/// Sets the global theme of the app /// Sets the global theme of the app
/// Light = 0 /// Light = 0
/// Dark = 1 /// Dark = 1
/// Midnight = 2
/// </summary> /// </summary>
public static void SetGlobalTheme(int theme) public static void SetGlobalTheme(int theme)
{ {
if (currentTheme == theme)
return;
currentTheme = theme; currentTheme = theme;
//Make a seperate list for all current forms (causes issues otherwise) //Make a seperate list for all current forms (causes issues otherwise)
@@ -91,19 +95,21 @@ namespace VRCX
private static void SetThemeToGlobal(IntPtr handle) private static void SetThemeToGlobal(IntPtr handle)
{ {
int whiteColor = 0xFFFFFF; var whiteColor = 0xFFFFFF;
int blackColor = 0x000000; var blackColor = 0x000000;
if (GetTheme(handle) != currentTheme) var greyColor = 0x2B2B2B;
{
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));
if (currentTheme == 1) 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 == 2)
PInvoke.DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref blackColor, sizeof(int)); 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 else
PInvoke.DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref whiteColor, sizeof(int)); PInvoke.DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref whiteColor, sizeof(int));
} }
}
private static int GetTheme(IntPtr handle) private static int GetTheme(IntPtr handle)
{ {
+17 -10
View File
@@ -5,6 +5,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using NLog; using NLog;
@@ -32,6 +33,8 @@ namespace VRCX
private DateTime tillDate; private DateTime tillDate;
public bool VrcClosedGracefully; public bool VrcClosedGracefully;
private readonly ConcurrentQueue<string> m_LogQueue = new ConcurrentQueue<string>(); // for electron 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 // NOTE
// FileSystemWatcher() is unreliable // FileSystemWatcher() is unreliable
@@ -371,6 +374,7 @@ namespace VRCX
return true; return true;
var location = line.Substring(lineOffset); var location = line.Substring(lineOffset);
location = CleanLocation.Replace(location, string.Empty);
AppendLog(new[] AppendLog(new[]
{ {
@@ -447,7 +451,8 @@ namespace VRCX
if (lineOffset >= line.Length) if (lineOffset >= line.Length)
return true; return true;
logContext.LocationDestination = line.Substring(lineOffset); var locationDestination = line.Substring(lineOffset);
logContext.LocationDestination = CleanLocation.Replace(locationDestination, string.Empty);
return true; return true;
} }
@@ -495,8 +500,8 @@ namespace VRCX
fileInfo.Name, fileInfo.Name,
ConvertLogTimeToISO8601(line), ConvertLogTimeToISO8601(line),
"player-joined", "player-joined",
userInfo.DisplayName ?? string.Empty, userInfo.DisplayName,
userInfo.UserId ?? string.Empty userInfo.UserId
}); });
return true; return true;
@@ -1351,14 +1356,15 @@ namespace VRCX
var inventoryIdIndex = info.IndexOf("inv_", StringComparison.Ordinal); var inventoryIdIndex = info.IndexOf("inv_", StringComparison.Ordinal);
var inventoryId = info.Substring(inventoryIdIndex); var inventoryId = info.Substring(inventoryIdIndex);
inventoryId = CleanId.Replace(inventoryId, string.Empty);
AppendLog(new[] AppendLog(new[]
{ {
fileInfo.Name, fileInfo.Name,
ConvertLogTimeToISO8601(line), ConvertLogTimeToISO8601(line),
"sticker-spawn", "sticker-spawn",
userId ?? string.Empty, userId,
displayName ?? string.Empty, displayName,
inventoryId inventoryId
}); });
@@ -1400,21 +1406,22 @@ namespace VRCX
return new string[][] { }; return new string[][] { };
} }
private static (string? DisplayName, string? UserId) ParseUserInfo(string userInfo) private static (string DisplayName, string UserId) ParseUserInfo(string userInfo)
{ {
string? userDisplayName; string userDisplayName;
string? userId; string userId;
int pos = userInfo.LastIndexOf(" (", StringComparison.Ordinal); var pos = userInfo.LastIndexOf(" (", StringComparison.Ordinal);
if (pos >= 0) if (pos >= 0)
{ {
userDisplayName = userInfo.Substring(0, pos); userDisplayName = userInfo.Substring(0, pos);
userId = userInfo.Substring(pos + 2, userInfo.LastIndexOf(')') - (pos + 2)); userId = userInfo.Substring(pos + 2, userInfo.LastIndexOf(')') - (pos + 2));
userId = CleanId.Replace(userId, string.Empty);
} }
else else
{ {
userDisplayName = userInfo; userDisplayName = userInfo;
userId = null; userId = string.Empty;
} }
return (userDisplayName, userId); return (userDisplayName, userId);
+3 -1
View File
@@ -17,8 +17,10 @@ public static class OverlayClient
private static readonly Uri WebsocketUri = new("ws://127.0.0.1:34582"); private static readonly Uri WebsocketUri = new("ws://127.0.0.1:34582");
private static WebsocketClient? _websocketClient; private static WebsocketClient? _websocketClient;
public static bool Connected =>
_websocketClient != null && _websocketClient.IsRunning;
public static bool ConnectedAndActive => public static bool ConnectedAndActive =>
_websocketClient != null && _websocketClient.IsRunning && Connected &&
OverlayProgram.VRCXVRInstance.IsActive(); OverlayProgram.VRCXVRInstance.IsActive();
public static async Task Init() public static async Task Init()
+3 -1
View File
@@ -42,7 +42,9 @@ internal static class OverlayProgram
private static async Task QuitProcess() private static async Task QuitProcess()
{ {
await Task.Delay(5000); await Task.Delay(5000);
while (OverlayClient.ConnectedAndActive) while (Program.LaunchDebug ?
OverlayClient.Connected :
OverlayClient.ConnectedAndActive)
{ {
await Task.Delay(500); await Task.Delay(500);
} }
+9 -3
View File
@@ -25,6 +25,7 @@ namespace VRCX
private readonly List<string[]> _deviceList; private readonly List<string[]> _deviceList;
private readonly ReaderWriterLockSlim _deviceListLock; private readonly ReaderWriterLockSlim _deviceListLock;
private bool _active; private bool _active;
private bool _isOverlayStarted;
private bool _menuButton; private bool _menuButton;
private int _overlayHand; private int _overlayHand;
private GLTextureWriter _overlayTextureWriter; private GLTextureWriter _overlayTextureWriter;
@@ -213,6 +214,7 @@ namespace VRCX
active = true; active = true;
SetupTextures(); SetupTextures();
_isOverlayStarted = true;
} }
while (system.PollNextEvent(ref e, (uint)Marshal.SizeOf(e))) while (system.PollNextEvent(ref e, (uint)Marshal.SizeOf(e)))
@@ -220,6 +222,7 @@ namespace VRCX
var type = (EVREventType)e.eventType; var type = (EVREventType)e.eventType;
if (type == EVREventType.VREvent_Quit) if (type == EVREventType.VREvent_Quit)
{ {
_isOverlayStarted = false;
active = false; active = false;
IsHmdAfk = false; IsHmdAfk = false;
OpenVR.Shutdown(); OpenVR.Shutdown();
@@ -290,6 +293,7 @@ namespace VRCX
else if (active) else if (active)
{ {
active = false; active = false;
_isOverlayStarted = false;
IsHmdAfk = false; IsHmdAfk = false;
OpenVR.Shutdown(); OpenVR.Shutdown();
_deviceListLock.EnterWriteLock(); _deviceListLock.EnterWriteLock();
@@ -848,9 +852,11 @@ namespace VRCX
public override void ExecuteVrOverlayFunction(string function, string json) public override void ExecuteVrOverlayFunction(string function, string json)
{ {
//if (_hmdOverlaySocket == null || !_hmdOverlaySocket.Connected) return; if (!_isOverlayStarted)
// if (_hmdOverlay.IsLoading) {
// Restart(); _overlayFunctionQueue.Clear();
return;
}
_overlayFunctionQueue.Enqueue(new KeyValuePair<string, string>(function, json)); _overlayFunctionQueue.Enqueue(new KeyValuePair<string, string>(function, json));
} }
+1 -1
View File
@@ -191,7 +191,7 @@ public class OverlayServer
public void UpdateVars(OverlayVars overlayVars) public void UpdateVars(OverlayVars overlayVars)
{ {
_overlayVars = overlayVars; _overlayVars = overlayVars;
if (!IsConnected() && overlayVars.Active) if (!IsConnected() && (overlayVars.Active || Program.LaunchDebug))
{ {
OverlayManager.StartOverlay(); OverlayManager.StartOverlay();
return; return;
+5 -2
View File
@@ -203,8 +203,11 @@ namespace VRCX
} }
logger.Fatal(e, "Unhandled Exception, program dying"); logger.Fatal(e, "Unhandled Exception, program dying");
MessageBox.Show(e.ToString(), "PLEASE REPORT IN https://vrcx.app/discord", MessageBoxButtons.OK, var result = MessageBox.Show(e.ToString(), $"{Version} crashed, open Discord for support?", MessageBoxButtons.YesNo, MessageBoxIcon.Error);
MessageBoxIcon.Error); if (result == DialogResult.Yes)
{
AppApiInstance.OpenLink("https://vrcx.app/discord");
}
Environment.Exit(0); Environment.Exit(0);
} }
} }
+7 -7
View File
@@ -93,21 +93,21 @@
</Content> </Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CefSharp.OffScreen.NETCore" Version="141.0.110" /> <PackageReference Include="CefSharp.OffScreen.NETCore" Version="146.0.70" />
<PackageReference Include="CefSharp.WinForms.NETCore" Version="141.0.110" /> <PackageReference Include="CefSharp.WinForms.NETCore" Version="146.0.70" />
<PackageReference Include="DiscordRichPresence" Version="1.6.1.70" /> <PackageReference Include="DiscordRichPresence" Version="1.6.1.70" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" /> <PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="NLog" Version="6.0.7" /> <PackageReference Include="NLog" Version="6.0.7" />
<PackageReference Include="Silk.NET.Direct3D.Compilers" Version="2.22.0" /> <PackageReference Include="Silk.NET.Direct3D.Compilers" Version="2.23.0" />
<PackageReference Include="Silk.NET.Direct3D11" Version="2.22.0" /> <PackageReference Include="Silk.NET.Direct3D11" Version="2.23.0" />
<PackageReference Include="Silk.NET.DXGI" Version="2.22.0" /> <PackageReference Include="Silk.NET.DXGI" Version="2.23.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.22.0" /> <PackageReference Include="Silk.NET.Windowing" Version="2.23.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" /> <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" /> <PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" />
<PackageReference Include="System.Data.SQLite" Version="2.0.2" /> <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" /> <PackageReference Include="Websocket.Client" Version="5.3.0" />
</ItemGroup> </ItemGroup>
+3 -3
View File
@@ -77,8 +77,8 @@
</Content> </Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.JavaScript.NodeApi" Version="0.9.18" /> <PackageReference Include="Microsoft.JavaScript.NodeApi" Version="0.9.19" />
<PackageReference Include="Microsoft.JavaScript.NodeApi.Generator" Version="0.9.18" /> <PackageReference Include="Microsoft.JavaScript.NodeApi.Generator" Version="0.9.19" />
<PackageReference Include="DiscordRichPresence" Version="1.6.1.70" /> <PackageReference Include="DiscordRichPresence" Version="1.6.1.70" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="NLog" Version="6.0.7" /> <PackageReference Include="NLog" Version="6.0.7" />
@@ -86,7 +86,7 @@
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" /> <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.7" />
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" /> <PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" />
<PackageReference Include="System.Data.SQLite" Version="1.0.119" /> <!--DO NOT UPGRADE--> <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" /> <PackageReference Include="Websocket.Client" Version="5.3.0" />
</ItemGroup> </ItemGroup>
+9 -2
View File
@@ -143,6 +143,7 @@ namespace VRCX
#endif #endif
CookieContainer = new CookieContainer(); CookieContainer = new CookieContainer();
InitializeHttpClient(); InitializeHttpClient();
_cookieDirty = true;
SaveCookies(); SaveCookies();
} }
@@ -247,9 +248,15 @@ namespace VRCX
public void SetCookies(string cookies) 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 _cookieDirty = true; // force cookies to be saved for lastUserLoggedIn
+2
View File
@@ -32,6 +32,8 @@
;-------------------------------- ;--------------------------------
;General ;General
SetCompressor /SOLID lzma
SetCompressorDictSize 16
Unicode True Unicode True
Name "VRCX" Name "VRCX"
OutFile "VRCX_Setup.exe" OutFile "VRCX_Setup.exe"
+25 -20
View File
@@ -7,7 +7,7 @@
[![GitHub Workflow Status](https://github.com/vrcx-team/VRCX/actions/workflows/github_actions.yml/badge.svg)](https://github.com/vrcx-team/VRCX/actions/workflows/github_actions.yml) [![GitHub Workflow Status](https://github.com/vrcx-team/VRCX/actions/workflows/github_actions.yml/badge.svg)](https://github.com/vrcx-team/VRCX/actions/workflows/github_actions.yml)
[![VRCX Discord Invite](https://img.shields.io/discord/854071236363550763?color=%237289DA&logo=discord&logoColor=white&label=discord)](https://vrcx.app/discord) [![VRCX Discord Invite](https://img.shields.io/discord/854071236363550763?color=%237289DA&logo=discord&logoColor=white&label=discord)](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. 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.
@@ -27,36 +27,41 @@ Beta/nightly build available [here](https://vrcx.app/github/nightly) or in-app `
- :family: Friend, world, and avatar list management - :family: Friend, world, and avatar list management
- Manage your friends list, world/group/avatar lists outside of VRChat. - Manage your friends list, world/group/avatar lists outside of VRChat.
- Monitor the world/avatar activity of your friends and check their online status. - Monitor the activity of your friends and track their online status, locations, and avatars.
- Keep track of when you first added them and when you last saw them. - Track friendship history including add dates, time spent together, and name changes.
- See how much time you've spent together in worlds and how many times. - Save notes and memos to help remember how you met.
- Keep track of friend name changes. - :bar_chart: Customizable Dashboard with widgets
- Save notes to help remember how you met. - Build personalized multi-panel layouts with Feed, GameLog, and Instance widgets.
- :electric_plug: Automatically launch apps when you start VRChat - Create multiple dashboards, each with configurable event filters and column visibility.
- You can configure VRCX to launch other apps when you start VRChat. - :mag: Powerful search across all entities
- For example, you could have VRCX launch an OSC app or a voice changer app when VRChat opens up. - Search for users, worlds, avatars, and groups, or paste IDs and URLs for direct access.
- :mag: Search for avatars, users, worlds, and groups - Quick Search provides instant client-side fuzzy search across your friends, avatars, worlds, and groups.
- :earth_americas: Build a local, unrestricted world favorites list - :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! - :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 - :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 - :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 - :bar_chart: Improved Discord Rich Presence
- You can optionally display more information about your current instance in Discord. - Display detailed instance information in Discord, including world thumbnail, name, player count, and a join button for public lobbies.
- 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!
- :crystal_ball: VR Overlay with configurable live feed of all supported events/notifications - :crystal_ball: VR Overlay with configurable live feed of all supported events/notifications
- :outbox_tray: Upload avatar/world images without Unity - :outbox_tray: Upload and manage avatar/world images and details without Unity
- :page_facing_up: Manage and edit uploaded avatar/world details without Unity - :electric_plug: Automatically launch apps when you start VRChat
- :skull: Automatically restart and join last instance when VRC crashes - :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) - 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. - 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 # Screenshots
+133
View File
@@ -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
[![GitHub release](https://img.shields.io/github/release/vrcx-team/VRCX.svg)](https://github.com/vrcx-team/VRCX/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/vrcx-team/VRCX/total?color=6451f1)](https://github.com/vrcx-team/VRCX/releases/latest)
[![GitHub Workflow Status](https://github.com/vrcx-team/VRCX/actions/workflows/github_actions.yml/badge.svg)](https://github.com/vrcx-team/VRCX/actions/workflows/github_actions.yml)
[![VRCX Discord Invite](https://img.shields.io/discord/854071236363550763?color=%237289DA&logo=discord&logoColor=white&label=discord)](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.
+1 -1
View File
@@ -1 +1 @@
2026.01.04 2026.02.11
@@ -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();
+101
View File
@@ -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
View File
@@ -1,12 +1,13 @@
import { defineConfig } from 'eslint/config'; import { defineConfig } from 'eslint/config';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals'; import globals from 'globals';
import js from '@eslint/js'; import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue'; import pluginVue from 'eslint-plugin-vue';
import prettyImport from '@kamiya4047/eslint-plugin-pretty-import'; import oxlint from 'eslint-plugin-oxlint';
export default defineConfig([ export default defineConfig([
{
ignores: ['build/**', 'node_modules/**']
},
{ {
files: ['**/*.{js,mjs,cjs,vue}'], files: ['**/*.{js,mjs,cjs,vue}'],
plugins: { js }, plugins: { js },
@@ -32,7 +33,8 @@ export default defineConfig([
VERSION: 'readonly', VERSION: 'readonly',
NIGHTLY: 'readonly', NIGHTLY: 'readonly',
webApiService: 'readonly', webApiService: 'readonly',
process: 'readonly' process: 'readonly',
AppDebug: 'readonly'
} }
} }
}, },
@@ -41,7 +43,8 @@ export default defineConfig([
'**/webpack.*.js', '**/webpack.*.js',
'**/jest.config.js', '**/jest.config.js',
'src-electron/*.js', 'src-electron/*.js',
'src/localization/*.js' 'src/localization/*.js',
'src/shared/utils/localizationHelperCLI.js'
], ],
languageOptions: { languageOptions: {
sourceType: 'commonjs', sourceType: 'commonjs',
@@ -54,11 +57,15 @@ export default defineConfig([
files: [ files: [
'**/__tests__/**/*.{js,mjs,cjs,vue}', '**/__tests__/**/*.{js,mjs,cjs,vue}',
'**/*.spec.{js,mjs,cjs,vue}', '**/*.spec.{js,mjs,cjs,vue}',
'**/*.test.{js,mjs,cjs,vue}' '**/*.test.{js,mjs,cjs,vue}',
'vitest.setup.js'
], ],
languageOptions: { languageOptions: {
globals: { globals: {
...globals.jest ...globals.jest,
...globals.node,
vi: 'readonly',
vitest: 'readonly'
} }
} }
}, },
@@ -68,6 +75,25 @@ export default defineConfig([
'no-unused-vars': 'warn', 'no-unused-vars': 'warn',
'no-case-declarations': 'off', 'no-case-declarations': 'off',
'no-control-regex': 'warn', '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/no-mutating-props': 'warn',
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
@@ -75,18 +101,5 @@ export default defineConfig([
'vue/no-use-v-if-with-v-for': 'warn' 'vue/no-use-v-if-with-v-for': 'warn'
} }
}, },
{ ...oxlint.configs['flat/recommended']
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
]); ]);
-19
View File
@@ -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__/**'
]
};
+3614 -9370
View File
File diff suppressed because it is too large Load Diff
+74 -58
View File
@@ -1,99 +1,119 @@
{ {
"name": "VRCX", "name": "VRCX",
"description": "Friendship management tool for VRChat",
"private": true, "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", "main": "src-electron/main.js",
"scripts": { "scripts": {
"dev": "cross-env PLATFORM=windows vite serve src", "dev": "cross-env PLATFORM=windows vite serve src",
"dev-linux": "cross-env PLATFORM=linux 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", "localization": "node ./src/shared/utils/localizationHelperCLI.js",
"test": "jest", "lint": "npm run lint:oxlint && npm run lint:eslint",
"test:coverage": "jest --coverage", "lint:eslint": "eslint .",
"prod": "cross-env PLATFORM=windows vite build src", "lint:oxlint": "oxlint .",
"prod-linux": "cross-env PLATFORM=linux vite build src", "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": "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", "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": "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", "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" "start-electron": "electron . --hot-reload"
}, },
"repository": { "dependencies": {
"type": "git", "hazardous": "^0.3.0",
"url": "git+https://github.com/vrcx-team/VRCX.git" "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": { "devDependencies": {
"@electron/rebuild": "^4.0.2", "@dnd-kit/vue": "^0.3.2",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.4",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/noto-sans-jp": "^5.2.10", "@fontsource-variable/noto-sans-jp": "^5.2.10",
"@fontsource-variable/noto-sans-kr": "^5.2.10", "@fontsource-variable/noto-sans-kr": "^5.2.10",
"@fontsource-variable/noto-sans-sc": "^5.2.10", "@fontsource-variable/noto-sans-sc": "^5.2.10",
"@fontsource-variable/noto-sans-tc": "^5.2.10", "@fontsource-variable/noto-sans-tc": "^5.2.10",
"@internationalized/date": "^3.10.1", "@internationalized/date": "^3.12.0",
"@kamiya4047/eslint-plugin-pretty-import": "^0.1.6", "@pinia/testing": "^1.0.3",
"@sentry/vite-plugin": "^4.6.2", "@sentry/vite-plugin": "^4.9.1",
"@sentry/vue": "^10.34.0", "@sentry/vue": "^10.46.0",
"@tailwindcss/vite": "^4.1.18", "@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-table": "^8.21.3",
"@tanstack/vue-virtual": "^3.13.18", "@tanstack/vue-virtual": "^3.13.23",
"@types/jest": "^30.0.0", "@types/node": "^24.12.0",
"@types/node": "^25.0.8",
"@vee-validate/zod": "^4.15.1", "@vee-validate/zod": "^4.15.1",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.3", "@vitejs/plugin-vue-jsx": "^5.1.5",
"@vueuse/core": "^14.1.0", "@vitest/coverage-v8": "^4.1.2",
"animate.css": "^4.1.1", "@vue/test-utils": "^2.4.6",
"babel-runtime": "^6.26.0", "@vueuse/core": "^14.2.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.20",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"electron": "^39.2.7", "electron": "^40.8.5",
"electron-builder": "^26.4.0", "electron-builder": "^26.8.1",
"embla-carousel-vue": "^8.6.0", "embla-carousel-vue": "^8.6.0",
"esbuild-jest": "^0.5.0", "eslint": "^9.39.4",
"eslint": "^9.39.2", "eslint-plugin-jsdoc": "^62.8.1",
"eslint-config-prettier": "^10.1.8", "eslint-plugin-oxlint": "^1.57.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-vue": "^9.33.0", "eslint-plugin-vue": "^9.33.0",
"globals": "^17.0.0", "globals": "^17.4.0",
"jest": "^30.2.0", "graphology": "^0.26.0",
"lightningcss": "^1.30.2", "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", "lucide-vue-next": "^0.562.0",
"noty": "^3.2.0-beta-deprecated", "noty": "^3.2.0-beta-deprecated",
"oxfmt": "^0.40.0",
"oxlint": "^1.57.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"prettier": "^3.8.0", "reka-ui": "^2.9.2",
"reka-ui": "^2.7.0", "remixicon": "^4.9.1",
"remixicon": "^4.8.0", "sigma": "^3.0.2",
"sass-embedded": "^1.97.2", "tailwind-merge": "^3.5.0",
"tailwind-merge": "^3.4.0", "tailwindcss": "^4.2.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vee-validate": "^4.15.1", "vee-validate": "^4.15.1",
"vite": "^7.3.1", "vite": "^8.0.3",
"vue": "^3.5.26", "vitest": "^4.1.2",
"vue-i18n": "^11.2.8", "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-json-pretty": "^2.6.0",
"vue-marquee-text-component": "^2.0.1", "vue-marquee-text-component": "^2.0.1",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
"vue-showdown": "^4.2.0", "vue-showdown": "^4.2.0",
"vue-sonner": "^2.0.9", "vue-sonner": "^2.0.9",
"worker-timers": "^8.0.28", "worker-timers": "^8.0.31",
"yargs": "^18.0.0", "yargs": "^18.0.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"engines": {
"node": ">=24.10.0",
"npm": ">=11.5.0"
},
"build": { "build": {
"appId": "app.vrcx", "appId": "app.vrcx",
"productName": "VRCX", "productName": "VRCX",
@@ -176,9 +196,5 @@
"category": "public.app-category.utilities", "category": "public.app-category.utilities",
"executableName": "VRCX" "executableName": "VRCX"
} }
},
"dependencies": {
"hazardous": "^0.3.0",
"node-api-dotnet": "^0.9.18"
} }
} }
-18
View File
@@ -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"]
}
+65 -13
View File
@@ -49,6 +49,10 @@ let isOverlayActive = false;
let appIsQuitting = false; let appIsQuitting = false;
const rootDir = app.getAppPath(); const rootDir = app.getAppPath();
let tray = null;
let trayIcon = null;
let trayIconNotify = null;
// Get launch arguments // Get launch arguments
let appImagePath = process.env.APPIMAGE; let appImagePath = process.env.APPIMAGE;
const args = process.argv.slice(1); const args = process.argv.slice(1);
@@ -70,12 +74,19 @@ if (process.defaultApp && process.platform !== 'win32') {
} }
} }
const version = getVersion();
const homePath = getHomePath(); const homePath = getHomePath();
tryRelaunchWithArgs(args); tryRelaunchWithArgs(args);
tryCopyFromWinePrefix(); 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'); const armPath = path.join(rootDir, 'build/Electron/VRCX-Electron-arm64.cjs');
if (fs.existsSync(armPath)) { if (process.arch === 'arm64' && fs.existsSync(armPath)) {
require(armPath); require(armPath);
} else { } else {
require(path.join(rootDir, 'build/Electron/VRCX-Electron.cjs')); require(path.join(rootDir, 'build/Electron/VRCX-Electron.cjs'));
@@ -94,15 +105,13 @@ const OVERLAY_SHARED_WIDTH = Math.max(
OVERLAY_WRIST_FRAME_WIDTH, OVERLAY_WRIST_FRAME_WIDTH,
OVERLAY_HMD_FRAME_WIDTH OVERLAY_HMD_FRAME_WIDTH
); );
const OVERLAY_FRAME_SIZE = const OVERLAY_FRAME_SIZE = OVERLAY_SHARED_WIDTH * OVERLAY_SHARED_HEIGHT * 4;
OVERLAY_SHARED_WIDTH * OVERLAY_SHARED_HEIGHT * 4;
const OVERLAY_SHM_PATH = '/dev/shm/vrcx_overlay'; const OVERLAY_SHM_PATH = '/dev/shm/vrcx_overlay';
function createOverlayWindowShm() { function createOverlayWindowShm() {
fs.writeFileSync(OVERLAY_SHM_PATH, Buffer.alloc(OVERLAY_FRAME_SIZE + 1)); fs.writeFileSync(OVERLAY_SHM_PATH, Buffer.alloc(OVERLAY_FRAME_SIZE + 1));
} }
const version = getVersion();
interopApi.getDotNetObject('ProgramElectron').PreInit(version, args); interopApi.getDotNetObject('ProgramElectron').PreInit(version, args);
interopApi.getDotNetObject('VRCXStorage').Load(); interopApi.getDotNetObject('VRCXStorage').Load();
interopApi.getDotNetObject('ProgramElectron').Init(); interopApi.getDotNetObject('ProgramElectron').Init();
@@ -211,6 +220,7 @@ ipcMain.handle('app:restart', () => {
} }
} }
app.relaunch(options); app.relaunch(options);
destroyTray();
app.exit(0); app.exit(0);
} else { } else {
app.relaunch(); app.relaunch();
@@ -287,6 +297,7 @@ function tryRelaunchWithArgs(args) {
child.unref(); child.unref();
destroyTray();
app.exit(0); app.exit(0);
} }
@@ -297,6 +308,7 @@ function createWindow() {
const y = parseInt(VRCXStorage.Get('VRCX_LocationY')) || 0; const y = parseInt(VRCXStorage.Get('VRCX_LocationY')) || 0;
const width = parseInt(VRCXStorage.Get('VRCX_SizeWidth')) || 1920; const width = parseInt(VRCXStorage.Get('VRCX_SizeWidth')) || 1920;
const height = parseInt(VRCXStorage.Get('VRCX_SizeHeight')) || 1080; const height = parseInt(VRCXStorage.Get('VRCX_SizeHeight')) || 1080;
const zoomLevel = parseFloat(VRCXStorage.Get('VRCX_ZoomLevel')) || 0;
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
x, x,
y, y,
@@ -334,21 +346,31 @@ function createWindow() {
// Open the DevTools. // Open the DevTools.
// mainWindow.webContents.openDevTools() // mainWindow.webContents.openDevTools()
mainWindow.webContents.on('did-finish-load', () => {
mainWindow.webContents.setZoomLevel(zoomLevel);
});
mainWindow.webContents.on('before-input-event', (event, input) => { mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.control && input.key === '=') { if (input.control && input.key === '=') {
mainWindow.webContents.setZoomLevel( mainWindow.webContents.setZoomLevel(
mainWindow.webContents.getZoomLevel() + 1 mainWindow.webContents.getZoomLevel() + 1
); );
} }
if (input.control && input.key === '-') {
mainWindow.webContents.setZoomLevel(
mainWindow.webContents.getZoomLevel() - 1
);
}
}); });
mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => { mainWindow.webContents.on('zoom-changed', (event, zoomDirection) => {
const currentZoom = mainWindow.webContents.getZoomLevel(); let currentZoom = mainWindow.webContents.getZoomLevel();
if (zoomDirection === 'in') { if (zoomDirection === 'in') {
mainWindow.webContents.setZoomLevel(currentZoom + 1); mainWindow.webContents.setZoomLevel(++currentZoom);
} else { } else {
mainWindow.webContents.setZoomLevel(currentZoom - 1); mainWindow.webContents.setZoomLevel(--currentZoom);
} }
VRCXStorage.Set('VRCX_ZoomLevel', currentZoom.toString());
}); });
mainWindow.webContents.setVisualZoomLevelLimits(1, 5); mainWindow.webContents.setVisualZoomLevelLimits(1, 5);
@@ -459,9 +481,13 @@ function writeOverlayFrame(imageBuffer) {
} }
} }
let tray = null;
let trayIcon = null; function destroyTray() {
let trayIconNotify = null; if (tray) {
tray.destroy();
tray = null;
}
}
function createTray() { function createTray() {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
const image = nativeImage.createFromPath( const image = nativeImage.createFromPath(
@@ -521,8 +547,10 @@ function createTray() {
} }
function setTrayIconNotification(notify) { function setTrayIconNotification(notify) {
if (tray) {
tray.setImage(notify ? trayIconNotify : trayIcon); tray.setImage(notify ? trayIconNotify : trayIcon);
} }
}
async function installVRCX() { async function installVRCX() {
console.log('Home path:', homePath); console.log('Home path:', homePath);
@@ -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() { function getVRCXPath() {
if (process.platform === 'win32') { if (process.platform === 'win32') {
return path.join(process.env.APPDATA, 'VRCX'); 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') { } else if (process.platform === 'darwin') {
return path.join(process.env.HOME, 'Library/Application Support/VRCX'); 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() { 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. // Mark it as a quitting state to make macOS Dock's "Quit" action take effect.
appIsQuitting = true; appIsQuitting = true;
disposeOverlay(); disposeOverlay();
destroyTray();
}); });
app.on('window-all-closed', function () { app.on('window-all-closed', function () {
+2 -1
View File
@@ -1,5 +1,6 @@
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', { 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
View File
@@ -4,16 +4,15 @@
<div <div
id="x-app" id="x-app"
class="x-app" class="flex w-screen h-screen overflow-hidden cursor-default [&>.x-container]:pt-[15px]"
:class="{ 'with-macos-titlebar': isMacOS }" :class="{ 'pt-7': isMacOS }">
ondragenter="event.preventDefault()"
ondragover="event.preventDefault()"
ondrop="event.preventDefault()">
<RouterView></RouterView> <RouterView></RouterView>
<Toaster position="top-center"></Toaster> <Toaster position="top-center" :theme="theme"></Toaster>
<AlertDialogModal></AlertDialogModal> <AlertDialogModal></AlertDialogModal>
<PromptDialogModal></PromptDialogModal> <PromptDialogModal></PromptDialogModal>
<OtpDialogModal></OtpDialogModal>
<DatabaseUpgradeDialog></DatabaseUpgradeDialog>
<VRCXUpdateDialog></VRCXUpdateDialog> <VRCXUpdateDialog></VRCXUpdateDialog>
</div> </div>
@@ -24,29 +23,40 @@
<script setup> <script setup>
import { computed, onBeforeMount, onMounted } from 'vue'; 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 { Toaster } from './components/ui/sonner';
import { TooltipProvider } from './components/ui/tooltip'; import { TooltipProvider } from './components/ui/tooltip';
import { createGlobalStores } from './stores'; import { createGlobalStores } from './stores';
import { initNoty } from './plugin/noty'; import { initNoty } from './plugins/noty';
import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue'; import AlertDialogModal from './components/ui/alert-dialog/AlertDialogModal.vue';
import DatabaseUpgradeDialog from './components/dialogs/DatabaseUpgradeDialog.vue';
import MacOSTitleBar from './components/MacOSTitleBar.vue'; import MacOSTitleBar from './components/MacOSTitleBar.vue';
import OtpDialogModal from './components/ui/dialog/OtpDialogModal.vue';
import PromptDialogModal from './components/ui/dialog/PromptDialogModal.vue'; import PromptDialogModal from './components/ui/dialog/PromptDialogModal.vue';
import VRCXUpdateDialog from './components/dialogs/VRCXUpdateDialog.vue'; import VRCXUpdateDialog from './components/dialogs/VRCXUpdateDialog.vue';
import '@/styles/globals.css'; import '@/styles/globals.css';
import '@/app.css';
console.log(`isLinux: ${LINUX}`); console.log(`isLinux: ${LINUX}`);
const isMacOS = computed(() => navigator.platform.includes('Mac')); const isMacOS = computed(() => navigator.platform.includes('Mac'));
const theme = computed(() => {
return store.appearanceSettings.isDarkMode ? 'dark' : 'light';
});
initNoty(); initNoty();
const store = createGlobalStores(); const store = createGlobalStores();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.$pinia = store; 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(() => { onBeforeMount(() => {
@@ -54,17 +64,10 @@
}); });
onMounted(async () => { onMounted(async () => {
store.gameLog.getGameLogTable(); getGameLogTable();
await store.auth.migrateStoredUsers(); await store.auth.migrateStoredUsers();
store.auth.autoLoginAfterMounted(); store.auth.autoLoginAfterMounted();
store.vrcx.checkAutoBackupRestoreVrcRegistry(); store.vrcx.checkAutoBackupRestoreVrcRegistry();
store.game.checkVRChatDebugLogging(); runCheckVRChatDebugLoggingFlow();
}); });
</script> </script>
<style scoped>
/* Add title bar spacing for macOS */
.x-app.with-macos-titlebar {
padding-top: 28px;
}
</style>
+97
View File
@@ -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'
});
});
});
+53
View File
@@ -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'
});
});
});
+59
View File
@@ -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'
});
});
});
+63
View File
@@ -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
});
});
});
+435
View File
@@ -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
View File
@@ -1,5 +1,5 @@
import { request } from '../service/request'; import { request } from '../services/request';
import { useUserStore } from '../stores'; import { handleConfig } from '../coordinators/userCoordinator';
const loginReq = { const loginReq = {
/** /**
@@ -63,7 +63,7 @@ const loginReq = {
const args = { const args = {
json json
}; };
useUserStore().handleConfig(args); handleConfig(args);
return args; return args;
}); });
} }
+44 -7
View File
@@ -1,5 +1,7 @@
import { request } from '../service/request'; import { patchAndRefetchActiveQuery, queryKeys } from '../queries';
import { request } from '../services/request';
import { useUserStore } from '../stores'; import { useUserStore } from '../stores';
import { applyCurrentUser } from '../coordinators/userCoordinator';
const avatarReq = { const avatarReq = {
/** /**
@@ -46,6 +48,15 @@ const avatarReq = {
json, json,
params params
}; };
patchAndRefetchActiveQuery({
queryKey: queryKeys.avatar(params.id),
nextData: args
}).catch((err) => {
console.error(
'Failed to refresh avatar query after mutation:',
err
);
});
return args; return args;
}); });
}, },
@@ -55,7 +66,6 @@ const avatarReq = {
* @returns {Promise<{json: any, params}>} * @returns {Promise<{json: any, params}>}
*/ */
selectAvatar(params) { selectAvatar(params) {
const userStore = useUserStore();
return request(`avatars/${params.avatarId}/select`, { return request(`avatars/${params.avatarId}/select`, {
method: 'PUT', method: 'PUT',
params params
@@ -64,17 +74,29 @@ const avatarReq = {
json, json,
params 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; return args;
}); });
}, },
/** /**
* @param {{ avatarId: string }} params * @param {{ avatarId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
selectFallbackAvatar(params) { selectFallbackAvatar(params) {
const userStore = useUserStore();
return request(`avatars/${params.avatarId}/selectfallback`, { return request(`avatars/${params.avatarId}/selectfallback`, {
method: 'PUT', method: 'PUT',
params params
@@ -83,14 +105,27 @@ const avatarReq = {
json, json,
params 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; return args;
}); });
}, },
/** /**
* @param {{ avatarId: string }} params * @param {{ avatarId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
deleteAvatar(params) { deleteAvatar(params) {
return request(`avatars/${params.avatarId}`, { return request(`avatars/${params.avatarId}`, {
@@ -193,6 +228,8 @@ const avatarReq = {
/** /**
* @param {{ imageData: string, avatarId: string }} * @param {{ imageData: string, avatarId: string }}
* @param imageData
* @param avatarId
* @returns {Promise<{json: any, params}>} * @returns {Promise<{json: any, params}>}
*/ */
uploadAvatarGalleryImage(imageData, avatarId) { uploadAvatarGalleryImage(imageData, avatarId) {
+3 -3
View File
@@ -1,4 +1,4 @@
import { request } from '../service/request'; import { request } from '../services/request';
const avatarModerationReq = { const avatarModerationReq = {
getAvatarModerations() { getAvatarModerations() {
@@ -14,7 +14,7 @@ const avatarModerationReq = {
/** /**
* @param {{ avatarModerationType: string, targetAvatarId: string }} params * @param {{ avatarModerationType: string, targetAvatarId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
sendAvatarModeration(params) { sendAvatarModeration(params) {
return request('auth/user/avatarmoderations', { return request('auth/user/avatarmoderations', {
@@ -31,7 +31,7 @@ const avatarModerationReq = {
/** /**
* @param {{ avatarModerationType: string, targetAvatarId: string }} params * @param {{ avatarModerationType: string, targetAvatarId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
deleteAvatarModeration(params) { deleteAvatarModeration(params) {
return request( return request(
+35 -8
View File
@@ -1,10 +1,33 @@
import { useFavoriteStore, useUserStore } from '../stores'; 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() { function getCurrentUserId() {
return useUserStore().currentUser.id; return useUserStore().currentUser.id;
} }
/**
*
*/
function refetchActiveFavoriteQueries() {
queryClient
.invalidateQueries({
queryKey: ['favorite'],
refetchType: 'active'
})
.catch((err) => {
console.error('Failed to refresh favorite queries:', err);
});
}
const favoriteReq = { const favoriteReq = {
getFavoriteLimits() { getFavoriteLimits() {
return request('auth/user/favoritelimits', { return request('auth/user/favoritelimits', {
@@ -45,14 +68,15 @@ const favoriteReq = {
json, json,
params params
}; };
useFavoriteStore().handleFavoriteAdd(args); handleFavoriteAdd(args);
refetchActiveFavoriteQueries();
return args; return args;
}); });
}, },
/** /**
* @param {{ objectId: string }} params * @param {{ objectId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
deleteFavorite(params) { deleteFavorite(params) {
return request(`favorites/${params.objectId}`, { return request(`favorites/${params.objectId}`, {
@@ -62,14 +86,15 @@ const favoriteReq = {
json, json,
params params
}; };
useFavoriteStore().handleFavoriteDelete(params.objectId); handleFavoriteDelete(params.objectId);
refetchActiveFavoriteQueries();
return args; return args;
}); });
}, },
/** /**
* @param {{ n: number, offset: number, type: string }} params * @param {{ n: number, offset: number, type: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getFavoriteGroups(params) { getFavoriteGroups(params) {
return request('favorite/groups', { return request('favorite/groups', {
@@ -87,7 +112,7 @@ const favoriteReq = {
/** /**
* *
* @param {{ type: string, group: string, displayName?: string, visibility?: string }} params group is a name * @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) { saveFavoriteGroup(params) {
return request( return request(
@@ -101,6 +126,7 @@ const favoriteReq = {
json, json,
params params
}; };
refetchActiveFavoriteQueries();
return args; return args;
}); });
}, },
@@ -110,7 +136,7 @@ const favoriteReq = {
* type: string, * type: string,
* group: string * group: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
clearFavoriteGroup(params) { clearFavoriteGroup(params) {
return request( return request(
@@ -124,7 +150,8 @@ const favoriteReq = {
json, json,
params params
}; };
useFavoriteStore().handleFavoriteGroupClear(args); handleFavoriteGroupClear(args);
refetchActiveFavoriteQueries();
return args; return args;
}); });
}, },
+22 -3
View File
@@ -1,5 +1,21 @@
import { request } from '../service/request'; import { queryClient } from '../queries';
import { request } from '../services/request';
import { useUserStore } from '../stores/user'; 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 = { const friendReq = {
/** /**
@@ -7,7 +23,6 @@ const friendReq = {
* @type {import('../types/api/friend').GetFriends} * @type {import('../types/api/friend').GetFriends}
*/ */
getFriends(params) { getFriends(params) {
const userStore = useUserStore();
return request('auth/user/friends', { return request('auth/user/friends', {
method: 'GET', method: 'GET',
params params
@@ -21,7 +36,7 @@ const friendReq = {
console.error('/friends gave us garbage', user); console.error('/friends gave us garbage', user);
continue; continue;
} }
userStore.applyUser(user); applyUser(user);
} }
return args; return args;
}); });
@@ -39,6 +54,7 @@ const friendReq = {
json, json,
params params
}; };
refetchActiveFriendListQueries();
return args; return args;
}); });
}, },
@@ -55,12 +71,14 @@ const friendReq = {
json, json,
params params
}; };
refetchActiveFriendListQueries();
return args; return args;
}); });
}, },
/** /**
* @param {{ userId: string }} params * @param {{ userId: string }} params
* @param customMsg
* @returns {Promise<{json: any, params: { userId: string }}>} * @returns {Promise<{json: any, params: { userId: string }}>}
*/ */
deleteFriend(params, customMsg) { deleteFriend(params, customMsg) {
@@ -72,6 +90,7 @@ const friendReq = {
json, json,
params params
}; };
refetchActiveFriendListQueries();
return args; return args;
}); });
}, },
+75 -60
View File
@@ -1,9 +1,32 @@
import { useGroupStore, useUserStore } from '../stores'; import { useUserStore } from '../stores';
import { request } from '../service/request'; import { applyGroup } from '../coordinators/groupCoordinator';
import { queryClient } from '../queries';
import { request } from '../services/request';
/**
*
*/
function getCurrentUserId() { function getCurrentUserId() {
return useUserStore().currentUser.id; 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 = { const groupReq = {
/** /**
* @param {string} groupId * @param {string} groupId
@@ -20,13 +43,14 @@ const groupReq = {
groupId, groupId,
params params
}; };
refetchActiveGroupScope(groupId);
return args; return args;
}); });
}, },
/** /**
* @param {{ groupId: string }} params * @param {{ groupId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
cancelGroupRequest(params) { cancelGroupRequest(params) {
return request(`groups/${params.groupId}/requests`, { return request(`groups/${params.groupId}/requests`, {
@@ -42,7 +66,7 @@ const groupReq = {
/** /**
* @param {{ groupId: string, postId: string }} params * @param {{ groupId: string, postId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
deleteGroupPost(params) { deleteGroupPost(params) {
return request(`groups/${params.groupId}/posts/${params.postId}`, { return request(`groups/${params.groupId}/posts/${params.postId}`, {
@@ -52,6 +76,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -69,39 +94,13 @@ const groupReq = {
json, json,
params params
}; };
args.ref = applyGroup(json);
return args; 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 * @param {{ userId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getRepresentedGroup(params) { getRepresentedGroup(params) {
return request(`users/${params.userId}/groups/represented`, { return request(`users/${params.userId}/groups/represented`, {
@@ -116,7 +115,7 @@ const groupReq = {
}, },
/** /**
* @param {{ userId: string }} params * @param {{ userId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroups(params) { getGroups(params) {
return request(`users/${params.userId}/groups`, { return request(`users/${params.userId}/groups`, {
@@ -131,7 +130,7 @@ const groupReq = {
}, },
/** /**
* @param {{ groupId: string }} params * @param {{ groupId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
joinGroup(params) { joinGroup(params) {
return request(`groups/${params.groupId}/join`, { return request(`groups/${params.groupId}/join`, {
@@ -141,12 +140,13 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
/** /**
* @param {{ groupId: string }} params * @param {{ groupId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
leaveGroup(params) { leaveGroup(params) {
return request(`groups/${params.groupId}/leave`, { return request(`groups/${params.groupId}/leave`, {
@@ -156,12 +156,13 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
/** /**
* @param {{ query: string }} params * @param {{ query: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
groupStrictsearch(params) { groupStrictsearch(params) {
return request(`groups/strictsearch`, { return request(`groups/strictsearch`, {
@@ -183,6 +184,9 @@ const groupReq = {
isSubscribedToAnnouncements: bool, isSubscribedToAnnouncements: bool,
managerNotes: string managerNotes: string
} }
* @param userId
* @param groupId
* @param params
*/ */
setGroupMemberProps(userId, groupId, params) { setGroupMemberProps(userId, groupId, params) {
return request(`groups/${groupId}/members/${userId}`, { return request(`groups/${groupId}/members/${userId}`, {
@@ -195,6 +199,7 @@ const groupReq = {
groupId, groupId,
params params
}; };
refetchActiveGroupScope(groupId);
return args; return args;
}); });
}, },
@@ -204,7 +209,7 @@ const groupReq = {
* groupId: string, * groupId: string,
* roleId: string * roleId: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
addGroupMemberRole(params) { addGroupMemberRole(params) {
return request( return request(
@@ -217,6 +222,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -226,7 +232,7 @@ const groupReq = {
* groupId: string, * groupId: string,
* roleId: string * roleId: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
removeGroupMemberRole(params) { removeGroupMemberRole(params) {
return request( return request(
@@ -239,6 +245,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -259,7 +266,7 @@ const groupReq = {
n: number, n: number,
offset: number offset: number
}} params }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupPosts(params) { getGroupPosts(params) {
return request(`groups/${params.groupId}/posts`, { return request(`groups/${params.groupId}/posts`, {
@@ -282,6 +289,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -294,6 +302,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -302,7 +311,7 @@ const groupReq = {
* groupId: string, * groupId: string,
* userId: string * userId: string
* }} params * }} params
* @return { Promise<{json: any, params, ref?: any}> } * @returns { Promise<{json: any, params, ref?: any}> }
*/ */
getGroupMember(params) { getGroupMember(params) {
return request(`groups/${params.groupId}/members/${params.userId}`, { return request(`groups/${params.groupId}/members/${params.userId}`, {
@@ -321,7 +330,7 @@ const groupReq = {
* n: number, * n: number,
* offset: number * offset: number
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupMembers(params) { getGroupMembers(params) {
return request(`groups/${params.groupId}/members`, { return request(`groups/${params.groupId}/members`, {
@@ -342,7 +351,7 @@ const groupReq = {
* n: number, * n: number,
* offset: number * offset: number
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupMembersSearch(params) { getGroupMembersSearch(params) {
return request(`groups/${params.groupId}/members/search`, { return request(`groups/${params.groupId}/members/search`, {
@@ -360,7 +369,7 @@ const groupReq = {
* @param {{ * @param {{
* groupId: string * groupId: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
blockGroup(params) { blockGroup(params) {
return request(`groups/${params.groupId}/block`, { return request(`groups/${params.groupId}/block`, {
@@ -370,6 +379,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -378,7 +388,7 @@ const groupReq = {
* groupId: string, * groupId: string,
* userId: string * userId: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
unblockGroup(params) { unblockGroup(params) {
return request(`groups/${params.groupId}/members/${params.userId}`, { return request(`groups/${params.groupId}/members/${params.userId}`, {
@@ -388,6 +398,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -396,7 +407,7 @@ const groupReq = {
* groupId: string, * groupId: string,
* userId: string * userId: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
sendGroupInvite(params) { sendGroupInvite(params) {
return request(`groups/${params.groupId}/invites`, { return request(`groups/${params.groupId}/invites`, {
@@ -417,7 +428,7 @@ const groupReq = {
* groupId: string, * groupId: string,
* userId: string * userId: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
kickGroupMember(params) { kickGroupMember(params) {
return request(`groups/${params.groupId}/members/${params.userId}`, { return request(`groups/${params.groupId}/members/${params.userId}`, {
@@ -427,12 +438,13 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
/** /**
* @param {{ groupId: string, userId: string }} params * @param {{ groupId: string, userId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
banGroupMember(params) { banGroupMember(params) {
return request(`groups/${params.groupId}/bans`, { return request(`groups/${params.groupId}/bans`, {
@@ -445,6 +457,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -456,12 +469,13 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
/** /**
* @param {{ groupId: string, userId: string }} params * @param {{ groupId: string, userId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
deleteSentGroupInvite(params) { deleteSentGroupInvite(params) {
return request(`groups/${params.groupId}/invites/${params.userId}`, { return request(`groups/${params.groupId}/invites/${params.userId}`, {
@@ -496,6 +510,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -510,7 +525,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -526,6 +541,7 @@ const groupReq = {
json, json,
params params
}; };
refetchActiveGroupScope(params.groupId);
return args; return args;
}); });
}, },
@@ -543,7 +559,7 @@ const groupReq = {
}, },
/** /**
* @param {{ groupId: string }} params * @param {{ groupId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupAuditLogTypes(params) { getGroupAuditLogTypes(params) {
return request(`groups/${params.groupId}/auditLogTypes`, { return request(`groups/${params.groupId}/auditLogTypes`, {
@@ -557,8 +573,8 @@ const groupReq = {
}); });
}, },
/** /**
* @param {{ groupId: string, n: number, offset: number, eventTypes?: array }} params * @param {{groupId: string, n: number, offset: number, eventTypes?: Array}} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupLogs(params) { getGroupLogs(params) {
return request(`groups/${params.groupId}/auditLogs`, { return request(`groups/${params.groupId}/auditLogs`, {
@@ -574,7 +590,7 @@ const groupReq = {
}, },
/** /**
* @param {{ groupId: string }} params * @param {{ groupId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupInvites(params) { getGroupInvites(params) {
return request(`groups/${params.groupId}/invites`, { return request(`groups/${params.groupId}/invites`, {
@@ -590,7 +606,7 @@ const groupReq = {
}, },
/** /**
* @param {{ groupId: string }} params * @param {{ groupId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupJoinRequests(params) { getGroupJoinRequests(params) {
return request(`groups/${params.groupId}/requests`, { return request(`groups/${params.groupId}/requests`, {
@@ -606,7 +622,7 @@ const groupReq = {
}, },
/** /**
* @param {{ groupId: string }} params * @param {{ groupId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupInstances(params) { getGroupInstances(params) {
return request( return request(
@@ -624,7 +640,7 @@ const groupReq = {
}, },
/** /**
* @param {{ groupId: string }} params * @param {{ groupId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupRoles(params) { getGroupRoles(params) {
return request(`groups/${params.groupId}/roles`, { return request(`groups/${params.groupId}/roles`, {
@@ -657,7 +673,7 @@ const groupReq = {
order: string, order: string,
sortBy: string sortBy: string
}} params }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
groupSearch(params) { groupSearch(params) {
return request(`groups`, { return request(`groups`, {
@@ -678,7 +694,7 @@ const groupReq = {
n: number, n: number,
offset: number offset: number
}} params }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupGallery(params) { getGroupGallery(params) {
return request( return request(
@@ -718,7 +734,7 @@ const groupReq = {
groupId: string, groupId: string,
eventId: string eventId: string
}} params }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getGroupCalendarEvent(params) { getGroupCalendarEvent(params) {
return request(`calendar/${params.groupId}/${params.eventId}`, { return request(`calendar/${params.groupId}/${params.eventId}`, {
@@ -731,7 +747,6 @@ const groupReq = {
return args; return args;
}); });
}, },
/** /**
* @type {import('../types/api/group').GetCalendars} * @type {import('../types/api/group').GetCalendars}
*/ */
+5 -4
View File
@@ -1,5 +1,6 @@
import { useAvatarStore, useWorldStore } from '../stores'; import { useAvatarStore, useWorldStore } from '../stores';
import { request } from '../service/request'; import { applyWorld } from '../coordinators/worldCoordinator';
import { request } from '../services/request';
const imageReq = { const imageReq = {
async uploadAvatarFailCleanup(id) { async uploadAvatarFailCleanup(id) {
@@ -21,7 +22,7 @@ const imageReq = {
} catch (error) { } catch (error) {
console.error('Failed to cleanup avatar upload:', error); console.error('Failed to cleanup avatar upload:', error);
} }
avatarStore.avatarDialog.loading = false; avatarStore.setAvatarDialogLoading(false);
}, },
async uploadAvatarImage(params, fileId) { async uploadAvatarImage(params, fileId) {
@@ -154,7 +155,7 @@ const imageReq = {
} catch (error) { } catch (error) {
console.error('Failed to cleanup world upload:', error); console.error('Failed to cleanup world upload:', error);
} }
worldStore.worldDialog.loading = false; worldStore.setWorldDialogLoading(false);
}, },
async uploadWorldImage(params, fileId) { async uploadWorldImage(params, fileId) {
@@ -267,7 +268,7 @@ const imageReq = {
json, json,
params params
}; };
args.ref = worldStore.applyWorld(json); args.ref = applyWorld(json);
return args; return args;
}); });
}, },
+6 -3
View File
@@ -3,7 +3,7 @@
* Export all API requests from here * Export all API requests from here
*/ */
import { request } from '../service/request'; import { request } from '../services/request';
import authRequest from './auth'; import authRequest from './auth';
import avatarModerationRequest from './avatarModeration'; import avatarModerationRequest from './avatarModeration';
@@ -19,6 +19,7 @@ import miscRequest from './misc';
import notificationRequest from './notification'; import notificationRequest from './notification';
import playerModerationRequest from './playerModeration'; import playerModerationRequest from './playerModeration';
import propRequest from './prop'; import propRequest from './prop';
import queryRequest from './queryRequest';
import userRequest from './user'; import userRequest from './user';
import vrcPlusIconRequest from './vrcPlusIcon'; import vrcPlusIconRequest from './vrcPlusIcon';
import vrcPlusImageRequest from './vrcPlusImage'; import vrcPlusImageRequest from './vrcPlusImage';
@@ -43,7 +44,8 @@ window.request = {
groupRequest, groupRequest,
inventoryRequest, inventoryRequest,
propRequest, propRequest,
imageRequest imageRequest,
queryRequest
}; };
export { export {
@@ -65,5 +67,6 @@ export {
groupRequest, groupRequest,
inventoryRequest, inventoryRequest,
propRequest, propRequest,
imageRequest imageRequest,
queryRequest
}; };
+2 -31
View File
@@ -1,7 +1,7 @@
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { i18n } from '../plugin/i18n'; import { i18n } from '../plugins/i18n';
import { request } from '../service/request'; import { request } from '../services/request';
import { useInstanceStore } from '../stores'; import { useInstanceStore } from '../stores';
const instanceReq = { 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} * @type {import('../types/api/instance').CreateInstance}
*/ */
+18 -1
View File
@@ -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 = { const inventoryReq = {
/** /**
@@ -67,6 +82,7 @@ const inventoryReq = {
json, json,
params params
}; };
refetchActiveInventoryQueries();
return args; return args;
}); });
}, },
@@ -102,6 +118,7 @@ const inventoryReq = {
json, json,
params params
}; };
refetchActiveInventoryQueries();
return args; return args;
}); });
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { request } from '../service/request'; import { request } from '../services/request';
import { useUserStore } from '../stores'; import { useUserStore } from '../stores';
function getCurrentUserId() { function getCurrentUserId() {
+12 -3
View File
@@ -1,6 +1,10 @@
import { request } from '../service/request'; import { queryClient, queryKeys } from '../queries';
import { request } from '../services/request';
import { useUserStore } from '../stores'; import { useUserStore } from '../stores';
/**
*
*/
function getCurrentUserId() { function getCurrentUserId() {
return useUserStore().currentUser.id; return useUserStore().currentUser.id;
} }
@@ -38,7 +42,7 @@ const miscReq = {
* reason: string, * reason: string,
* type: string * type: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
reportUser(params) { reportUser(params) {
return request(`feedback/${params.userId}/user`, { return request(`feedback/${params.userId}/user`, {
@@ -63,7 +67,7 @@ const miscReq = {
* version: number, * version: number,
* variant: string * variant: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
getFileAnalysis(params) { getFileAnalysis(params) {
return request( return request(
@@ -192,11 +196,16 @@ const miscReq = {
json, json,
fileId fileId
}; };
queryClient.removeQueries({
queryKey: queryKeys.file(fileId),
exact: true
});
return args; return args;
}); });
}, },
/** /**
* @param params
* @params {{ * @params {{
userId: string, userId: string,
emojiId: string emojiId: string
+50 -56
View File
@@ -1,15 +1,9 @@
import { useGalleryStore, useNotificationStore } from '../stores'; import { request } from '../services/request';
import { request } from '../service/request'; import { useGalleryStore } from '../stores';
/**
* @returns {any}
*/
function getGalleryStore() {
return useGalleryStore();
}
const notificationReq = { const notificationReq = {
/** @typedef {{ /**
* @typedef {{
* n: number, * n: number,
* offset: number, * offset: number,
* sent: boolean, * sent: boolean,
@@ -96,7 +90,7 @@ const notificationReq = {
* rsvp?: boolean, * rsvp?: boolean,
* }} params * }} params
* @param receiverUserId * @param receiverUserId
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
sendInvite(params, receiverUserId) { sendInvite(params, receiverUserId) {
return request(`invite/${receiverUserId}`, { return request(`invite/${receiverUserId}`, {
@@ -115,7 +109,7 @@ const notificationReq = {
return request(`invite/${receiverUserId}/photo`, { return request(`invite/${receiverUserId}/photo`, {
uploadImageLegacy: true, uploadImageLegacy: true,
postData: JSON.stringify(params), postData: JSON.stringify(params),
imageData: getGalleryStore().uploadImage imageData: useGalleryStore().uploadImage
}).then((json) => { }).then((json) => {
const args = { const args = {
json, json,
@@ -144,7 +138,7 @@ const notificationReq = {
return request(`requestInvite/${receiverUserId}/photo`, { return request(`requestInvite/${receiverUserId}/photo`, {
uploadImageLegacy: true, uploadImageLegacy: true,
postData: JSON.stringify(params), postData: JSON.stringify(params),
imageData: getGalleryStore().uploadImage imageData: useGalleryStore().uploadImage
}).then((json) => { }).then((json) => {
const args = { const args = {
json, json,
@@ -173,7 +167,7 @@ const notificationReq = {
return request(`invite/${inviteId}/response/photo`, { return request(`invite/${inviteId}/response/photo`, {
uploadImageLegacy: true, uploadImageLegacy: true,
postData: JSON.stringify(params), postData: JSON.stringify(params),
imageData: getGalleryStore().uploadImage, imageData: useGalleryStore().uploadImage,
inviteId inviteId
}).then((json) => { }).then((json) => {
const args = { const args = {
@@ -187,7 +181,7 @@ const notificationReq = {
/** /**
* @param {{ notificationId: string }} params * @param {{ notificationId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
acceptFriendRequestNotification(params) { acceptFriendRequestNotification(params) {
return request( return request(
@@ -195,26 +189,18 @@ const notificationReq = {
{ {
method: 'PUT' method: 'PUT'
} }
) ).then((json) => {
.then((json) => {
const args = { const args = {
json, json,
params params
}; };
useNotificationStore().handleNotificationAccept(args);
return 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 });
}
}); });
}, },
/** /**
* @param {{ notificationId: string }} params * @param {{ notificationId: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
hideNotification(params) { hideNotification(params) {
return request( return request(
@@ -227,7 +213,38 @@ const notificationReq = {
json, json,
params 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; return args;
}); });
}, },
@@ -238,12 +255,18 @@ const notificationReq = {
* responseType: string, * responseType: string,
* responseData: string * responseData: string
* }} params * }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
sendNotificationResponse(params) { sendNotificationResponse(params) {
return request(`notifications/${params.notificationId}/respond`, { return request(`notifications/${params.notificationId}/respond`, {
method: 'POST', method: 'POST',
params params
}).then((json) => {
const args = {
json,
params
};
return args;
}); });
}, },
@@ -260,35 +283,6 @@ const notificationReq = {
return args; 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; export default notificationReq;
+3 -3
View File
@@ -1,4 +1,4 @@
import { request } from '../service/request'; import { request } from '../services/request';
const playerModerationReq = { const playerModerationReq = {
getPlayerModerations() { getPlayerModerations() {
@@ -14,7 +14,7 @@ const playerModerationReq = {
/** /**
* @param {{ moderated: string, type: string }} params * @param {{ moderated: string, type: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
// old-way: POST auth/user/blocks {blocked:userId} // old-way: POST auth/user/blocks {blocked:userId}
sendPlayerModeration(params) { sendPlayerModeration(params) {
@@ -32,7 +32,7 @@ const playerModerationReq = {
/** /**
* @param {{ moderated: string, type: string }} params * @param {{ moderated: string, type: string }} params
* @return { Promise<{json: any, params}> } * @returns { Promise<{json: any, params}> }
*/ */
// old-way: PUT auth/user/unblocks {blocked:userId} // old-way: PUT auth/user/unblocks {blocked:userId}
deletePlayerModeration(params) { deletePlayerModeration(params) {
+1 -1
View File
@@ -1,4 +1,4 @@
import { request } from '../service/request'; import { request } from '../services/request';
const propReq = { const propReq = {
/** /**
+218
View File
@@ -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
View File
@@ -1,5 +1,7 @@
import { request } from '../service/request'; import { patchAndRefetchActiveQuery, queryKeys } from '../queries';
import { request } from '../services/request';
import { useUserStore } from '../stores'; import { useUserStore } from '../stores';
import { applyUser, applyCurrentUser } from '../coordinators/userCoordinator';
/** /**
* @returns {string} * @returns {string}
@@ -15,7 +17,6 @@ const userReq = {
* @type {import('../types/api/user').GetUser} * @type {import('../types/api/user').GetUser}
*/ */
getUser(params) { getUser(params) {
const userStore = useUserStore();
return request(`users/${params.userId}`, { return request(`users/${params.userId}`, {
method: 'GET' method: 'GET'
}).then((json) => { }).then((json) => {
@@ -28,39 +29,12 @@ const userReq = {
const args = { const args = {
json, json,
params, params,
ref: userStore.applyUser(json) ref: applyUser(json)
}; };
return args; 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} * @type {import('../types/api/user').GetUsers}
*/ */
@@ -82,7 +56,6 @@ const userReq = {
* @returns {Promise<{json: any, params: {tags: string[]}}>} * @returns {Promise<{json: any, params: {tags: string[]}}>}
*/ */
addUserTags(params) { addUserTags(params) {
const userStore = useUserStore();
return request(`users/${getCurrentUserId()}/addTags`, { return request(`users/${getCurrentUserId()}/addTags`, {
method: 'POST', method: 'POST',
params params
@@ -91,7 +64,7 @@ const userReq = {
json, json,
params params
}; };
userStore.applyCurrentUser(json); applyCurrentUser(json);
return args; return args;
}); });
}, },
@@ -101,7 +74,6 @@ const userReq = {
* @returns {Promise<{json: any, params: {tags: string[]}}>} * @returns {Promise<{json: any, params: {tags: string[]}}>}
*/ */
removeUserTags(params) { removeUserTags(params) {
const userStore = useUserStore();
return request(`users/${getCurrentUserId()}/removeTags`, { return request(`users/${getCurrentUserId()}/removeTags`, {
method: 'POST', method: 'POST',
params params
@@ -110,7 +82,7 @@ const userReq = {
json, json,
params params
}; };
userStore.applyCurrentUser(json); applyCurrentUser(json);
return args; return args;
}); });
}, },
@@ -139,7 +111,6 @@ const userReq = {
* @type {import('../types/api/user').GetCurrentUser} * @type {import('../types/api/user').GetCurrentUser}
*/ */
saveCurrentUser(params) { saveCurrentUser(params) {
const userStore = useUserStore();
return request(`users/${getCurrentUserId()}`, { return request(`users/${getCurrentUserId()}`, {
method: 'PUT', method: 'PUT',
params params
@@ -147,8 +118,17 @@ const userReq = {
const args = { const args = {
json, json,
params, 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; return args;
}); });
}, },
+18 -1
View File
@@ -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 = { const VRCPlusIconsReq = {
getFileList(params) { getFileList(params) {
@@ -22,6 +37,7 @@ const VRCPlusIconsReq = {
json, json,
fileId fileId
}; };
refetchActiveGalleryQueries();
return args; return args;
}); });
}, },
@@ -40,6 +56,7 @@ const VRCPlusIconsReq = {
json, json,
params params
}; };
refetchActiveGalleryQueries();
return args; return args;
}); });
} }
+24 -1
View File
@@ -1,9 +1,27 @@
import { request } from '../service/request'; import { queryClient } from '../queries';
import { request } from '../services/request';
import { useUserStore } from '../stores'; import { useUserStore } from '../stores';
/**
*
*/
function getCurrentUserId() { function getCurrentUserId() {
return useUserStore().currentUser.id; return useUserStore().currentUser.id;
} }
/**
*
*/
function refetchActiveGalleryQueries() {
queryClient
.invalidateQueries({
queryKey: ['gallery'],
refetchType: 'active'
})
.catch((err) => {
console.error('Failed to refresh gallery queries:', err);
});
}
const vrcPlusImageReq = { const vrcPlusImageReq = {
uploadGalleryImage(imageData) { uploadGalleryImage(imageData) {
const params = { const params = {
@@ -19,6 +37,7 @@ const vrcPlusImageReq = {
json, json,
params params
}; };
refetchActiveGalleryQueries();
return args; return args;
}); });
}, },
@@ -34,6 +53,7 @@ const vrcPlusImageReq = {
json, json,
params params
}; };
refetchActiveGalleryQueries();
return args; return args;
}); });
}, },
@@ -59,6 +79,7 @@ const vrcPlusImageReq = {
json, json,
printId printId
}; };
refetchActiveGalleryQueries();
return args; return args;
}); });
}, },
@@ -74,6 +95,7 @@ const vrcPlusImageReq = {
json, json,
params params
}; };
refetchActiveGalleryQueries();
return args; return args;
}); });
}, },
@@ -101,6 +123,7 @@ const vrcPlusImageReq = {
json, json,
params params
}; };
refetchActiveGalleryQueries();
return args; return args;
}); });
} }
+35 -39
View File
@@ -1,12 +1,12 @@
import { request } from '../service/request'; import { patchAndRefetchActiveQuery, queryKeys } from '../queries';
import { useWorldStore } from '../stores'; import { request } from '../services/request';
import { applyWorld } from '../coordinators/worldCoordinator';
const worldReq = { const worldReq = {
/** /**
* @type {import('../types/api/world').GetWorld} * @type {import('../types/api/world').GetWorld}
*/ */
getWorld(params) { getWorld(params) {
const worldStore = useWorldStore();
return request(`worlds/${params.worldId}`, { return request(`worlds/${params.worldId}`, {
method: 'GET' method: 'GET'
}).then((json) => { }).then((json) => {
@@ -14,43 +14,15 @@ const worldReq = {
json, json,
params params
}; };
args.ref = worldStore.applyWorld(json); args.ref = applyWorld(json);
return args; 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} * @type {import('../types/api/world').GetWorlds}
*/ */
getWorlds(params, option) { getWorlds(params, option) {
const worldStore = useWorldStore();
let endpoint = 'worlds'; let endpoint = 'worlds';
if (typeof option !== 'undefined') { if (typeof option !== 'undefined') {
endpoint = `worlds/${option}`; endpoint = `worlds/${option}`;
@@ -65,7 +37,7 @@ const worldReq = {
option option
}; };
for (const json of args.json) { for (const json of args.json) {
worldStore.applyWorld(json); applyWorld(json);
} }
return args; return args;
}); });
@@ -90,7 +62,6 @@ const worldReq = {
* @type {import('../types/api/world').SaveWorld} * @type {import('../types/api/world').SaveWorld}
*/ */
saveWorld(params) { saveWorld(params) {
const worldStore = useWorldStore();
return request(`worlds/${params.id}`, { return request(`worlds/${params.id}`, {
method: 'PUT', method: 'PUT',
params params
@@ -99,7 +70,16 @@ const worldReq = {
json, json,
params 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; return args;
}); });
}, },
@@ -109,7 +89,6 @@ const worldReq = {
* @returns {Promise<{json: any, params}>} * @returns {Promise<{json: any, params}>}
*/ */
publishWorld(params) { publishWorld(params) {
const worldStore = useWorldStore();
return request(`worlds/${params.worldId}/publish`, { return request(`worlds/${params.worldId}/publish`, {
method: 'PUT', method: 'PUT',
params params
@@ -118,7 +97,16 @@ const worldReq = {
json, json,
params 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; return args;
}); });
}, },
@@ -128,7 +116,6 @@ const worldReq = {
* @returns {Promise<{json: any, params}>} * @returns {Promise<{json: any, params}>}
*/ */
unpublishWorld(params) { unpublishWorld(params) {
const worldStore = useWorldStore();
return request(`worlds/${params.worldId}/publish`, { return request(`worlds/${params.worldId}/publish`, {
method: 'DELETE', method: 'DELETE',
params params
@@ -137,7 +124,16 @@ const worldReq = {
json, json,
params 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; return args;
}); });
}, },
-339
View File
@@ -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
View File
@@ -1,3 +1,4 @@
import { VueQueryPlugin } from '@tanstack/vue-query';
import { createApp } from 'vue'; import { createApp } from 'vue';
import { import {
@@ -6,8 +7,9 @@ import {
initPlugins, initPlugins,
initRouter, initRouter,
initSentry initSentry
} from './plugin'; } from './plugins';
import { initPiniaPlugins, pinia } from './stores'; import { initPiniaPlugins, pinia } from './stores';
import { queryClient } from './queries';
import App from './App.vue'; import App from './App.vue';
@@ -18,7 +20,7 @@ await initPiniaPlugins();
const app = createApp(App); const app = createApp(App);
app.use(pinia).use(i18n); app.use(pinia).use(i18n).use(VueQueryPlugin, { queryClient });
initComponents(app); initComponents(app);
initRouter(app); initRouter(app);
await initSentry(app); await initSentry(app);
+12 -8
View File
@@ -1,13 +1,16 @@
<template> <template>
<div @click="confirm" class="cursor-pointer w-fit align-top flex items-center"> <div @click="confirm" class="cursor-pointer w-fit align-top flex items-center">
<span class="flex items-center" <span v-if="avatarName" class="flex items-center mr-1"
>{{ avatarName }} <Lock v-if="avatarType && avatarType === '(own)'" class="h-4 w-4 mx-1" >{{ avatarName }} <Lock v-if="avatarType && avatarType === '(own)'" class="h-4 w-4 ml-1"
/></span> /></span>
<span v-else class="flex items-center mr-1 text-muted-foreground">{{
t('dialog.user.info.unknown_avatar')
}}</span>
<TooltipWrapper v-if="avatarTags"> <TooltipWrapper v-if="avatarTags">
<template #content> <template #content>
<span>{{ avatarTags }}</span> <span class="truncate">{{ avatarTags }}</span>
</template> </template>
<span v-if="avatarTags" style="font-size: 12px" class="truncate">{{ avatarTags }}</span> <span class="truncate text-xs text-muted-foreground">{{ avatarTags }}</span>
</TooltipWrapper> </TooltipWrapper>
</div> </div>
</template> </template>
@@ -15,11 +18,12 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { Lock } from 'lucide-vue-next'; import { Lock } from 'lucide-vue-next';
import { useI18n } from 'vue-i18n';
import { TooltipWrapper } from './ui/tooltip'; import { TooltipWrapper } from './ui/tooltip';
import { useAvatarStore } from '../stores'; import { getAvatarName, showAvatarAuthorDialog } from '../coordinators/avatarCoordinator';
const avatarStore = useAvatarStore(); const { t } = useI18n();
const props = defineProps({ const props = defineProps({
imageurl: String, imageurl: String,
@@ -49,7 +53,7 @@
ownerId = props.hintownerid; ownerId = props.hintownerid;
} else { } else {
try { try {
const info = await avatarStore.getAvatarName(props.imageurl); const info = await getAvatarName(props.imageurl);
avatarName.value = info.avatarName; avatarName.value = info.avatarName;
ownerId = info.ownerId; ownerId = info.ownerId;
} catch { } catch {
@@ -72,7 +76,7 @@
const confirm = () => { const confirm = () => {
if (!props.imageurl) return; 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 }); watch([() => props.imageurl, () => props.userid, () => props.avatartags], parse, { immediate: true });
+61 -27
View File
@@ -1,16 +1,19 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; 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 { ArrowUp } from 'lucide-vue-next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
const props = defineProps({ const props = defineProps({
// scroll DOM ref
target: { type: [String, Object], default: null }, target: { type: [String, Object], default: null },
// @tanstack/virtual instance
virtualizer: { type: [Object], default: null },
bottom: { type: Number, default: 20 }, bottom: { type: Number, default: 20 },
right: { type: Number, default: 20 }, right: { type: Number, default: 20 },
visibilityHeight: { type: Number, default: 200 }, visibilityHeight: { type: Number, default: 400 },
behavior: { behavior: {
type: String, type: String,
@@ -21,28 +24,26 @@
tooltip: { type: Boolean, default: true }, tooltip: { type: Boolean, default: true },
tooltipText: { type: String, default: 'Back to top' }, 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); const visible = ref(false);
let containerEl = null; let containerEl = null;
function resolveTarget() { function resolveElement(target) {
if (!props.target) return null; if (!target) return null;
if (typeof props.target === 'string') { if (typeof target === 'string') return document.querySelector(target);
return document.querySelector(props.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') { function getVirtualizer() {
if ('value' in props.target) { if (!props.virtualizer) return null;
return props.target.value; return 'value' in props.virtualizer ? props.virtualizer.value : props.virtualizer;
}
if ('$el' in props.target) {
return props.target.$el;
}
}
return props.target;
} }
function getScrollTop() { function getScrollTop() {
@@ -58,15 +59,21 @@
function scrollToTop() { function scrollToTop() {
const behavior = props.behavior === 'auto' ? 'auto' : 'smooth'; const behavior = props.behavior === 'auto' ? 'auto' : 'smooth';
if (!containerEl || typeof containerEl.scrollTo !== 'function') { const v = getVirtualizer();
window.scrollTo({ top: 0, behavior }); if (v?.scrollToIndex) {
v.scrollToIndex(0, { align: 'start', behavior: 'auto' });
return; 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() { function bind() {
containerEl = resolveTarget(); containerEl = resolveElement(props.target);
const target = containerEl && typeof containerEl.addEventListener === 'function' ? containerEl : window; const target = containerEl && typeof containerEl.addEventListener === 'function' ? containerEl : window;
target.addEventListener('scroll', handleScroll, { passive: true }); target.addEventListener('scroll', handleScroll, { passive: true });
@@ -94,17 +101,28 @@
unbind(); 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( 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> </script>
<template> <template>
<Teleport v-if="teleport" to="body"> <Teleport v-if="teleportTarget" :to="teleportTarget">
<Transition name="back-to-top"> <Transition name="back-to-top">
<div v-if="visible" :style="wrapperStyle"> <div v-if="visible" :style="wrapperStyle">
<TooltipProvider v-if="tooltip"> <Tooltip v-if="tooltip">
<Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button <Button
size="icon" size="icon"
@@ -115,11 +133,10 @@
<ArrowUp class="h-4 w-4" /> <ArrowUp class="h-4 w-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left"> <TooltipContent side="top">
{{ tooltipText }} {{ tooltipText }}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider>
<Button <Button
v-else v-else
@@ -136,6 +153,8 @@
<Transition v-else name="back-to-top"> <Transition v-else name="back-to-top">
<div v-if="visible" :style="wrapperStyle"> <div v-if="visible" :style="wrapperStyle">
<Tooltip v-if="tooltip">
<TooltipTrigger as-child>
<Button <Button
size="icon" size="icon"
variant="secondary" variant="secondary"
@@ -144,6 +163,21 @@
@click="scrollToTop"> @click="scrollToTop">
<ArrowUp class="h-4 w-4" /> <ArrowUp class="h-4 w-4" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent side="top">
{{ tooltipText }}
</TooltipContent>
</Tooltip>
<Button
v-else
size="icon"
variant="secondary"
class="rounded-full shadow"
aria-label="Back to top"
@click="scrollToTop">
<ArrowUp class="h-4 w-4" />
</Button>
</div> </div>
</Transition> </Transition>
</template> </template>
+29
View File
@@ -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>
+12 -8
View File
@@ -1,14 +1,12 @@
<template> <template>
<span @click="showUserDialog" class="cursor-pointer">{{ username }}</span> <span @click="openUserDialog" class="cursor-pointer">{{ username }}</span>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useUserStore } from '../stores'; import { queryRequest } from '../api';
import { userRequest } from '../api'; import { showUserDialog } from '../coordinators/userCoordinator';
const userStore = useUserStore();
const props = defineProps({ const props = defineProps({
userid: String, userid: String,
@@ -22,20 +20,26 @@
const username = ref(props.userid); const username = ref(props.userid);
/**
*
*/
async function parse() { async function parse() {
username.value = props.userid; username.value = props.userid;
if (props.hint) { if (props.hint) {
username.value = props.hint; username.value = props.hint;
} else if (props.userid) { } 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) { if (args?.json?.displayName) {
username.value = 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 }); watch([() => props.userid, () => props.location, () => props.forceUpdateKey], parse, { immediate: true });
+39 -6
View File
@@ -1,16 +1,24 @@
<template> <template>
<div style="overflow: hidden" :style="{ width: size + 'px', height: size + 'px' }"> <div ref="containerRef" class="relative overflow-hidden" :style="sizeStyle">
<div <div
v-if="image.frames" v-if="image.frames"
class="avatar" class="avatar absolute top-0 left-0"
:style="generateEmojiStyle(imageUrl, image.framesOverTime, image.frames, image.loopStyle, size)"></div> :style="generateEmojiStyle(imageUrl, image.framesOverTime, image.frames, image.loopStyle, effectiveSize)"></div>
<img v-else :src="imageUrl" class="avatar" :style="{ width: size + 'px', height: size + 'px' }" /> <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> </div>
</template> </template>
<script setup> <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 { extractFileId, generateEmojiStyle } from '../shared/utils';
import { useGalleryStore } from '../stores'; import { useGalleryStore } from '../stores';
@@ -19,7 +27,31 @@
const props = defineProps({ const props = defineProps({
imageUrl: { type: String, default: '' }, 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({ const image = ref({
@@ -37,6 +69,7 @@
return; return;
} }
} }
if (!fileId) return;
image.value = await getCachedEmoji(fileId); image.value = await getCachedEmoji(fileId);
} }
+22 -17
View File
@@ -1,15 +1,18 @@
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogPortal :to="portalTo"> <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 <RekaDialogContent
class="fixed inset-0 p-6 sm:p-10 border-0 bg-transparent shadow-none outline-none" class="fixed inset-0 p-6 sm:p-10 border-0 bg-transparent shadow-none outline-none"
@click="closeDialog"
@open-auto-focus.prevent @open-auto-focus.prevent
@close-auto-focus.prevent> @close-auto-focus.prevent>
<div ref="viewerEl" class="relative h-full w-full overflow-hidden select-none"> <div ref="viewerEl" class="relative h-full w-full overflow-hidden select-none">
<!-- toolbar --> <!-- toolbar -->
<div <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"> 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 <Button
variant="ghost" variant="ghost"
@@ -73,14 +76,13 @@
</Button> </Button>
</div> </div>
<div <div class="h-full w-full flex items-center justify-center" @wheel="onWheel">
class="h-full w-full flex items-center justify-center" <img
@wheel="onWheel"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
@pointermove="onPointerMove" @pointermove="onPointerMove"
@pointerup="onPointerUp" @pointerup="onPointerUp"
@pointercancel="onPointerUp"> @pointercancel="onPointerUp"
<img @click.stop
v-if="imageUrl" v-if="imageUrl"
:src="imageUrl" :src="imageUrl"
class="max-h-full max-w-full x-viewer-img" class="max-h-full max-w-full x-viewer-img"
@@ -95,21 +97,25 @@
<script setup> <script setup>
import { Copy, Download, RefreshCcw, RotateCcw, RotateCw, X, ZoomIn, ZoomOut } from 'lucide-vue-next'; 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 { DialogContent as RekaDialogContent, DialogOverlay as RekaDialogOverlay, DialogPortal } from 'reka-ui';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Dialog } from '@/components/ui/dialog'; import { Dialog } from '@/components/ui/dialog';
import { acquireModalPortalLayer } from '@/lib/modalPortalLayers'; import { acquireModalPortalLayer } from '@/lib/modalPortalLayers';
import { cn } from '@/lib/utils';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { useGeneralSettingsStore } from '@/stores/settings/general';
import { useI18n } from 'vue-i18n';
import Noty from 'noty'; import { extractFileId } from '../shared/utils';
import { escapeTag, extractFileId } from '../shared/utils';
import { useGalleryStore } from '../stores'; import { useGalleryStore } from '../stores';
const galleryStore = useGalleryStore(); const galleryStore = useGalleryStore();
const { fullscreenImageDialog } = storeToRefs(galleryStore); const { fullscreenImageDialog } = storeToRefs(galleryStore);
const { disableGpuAcceleration } = storeToRefs(useGeneralSettingsStore());
const { t } = useI18n();
const viewerEl = ref(null); const viewerEl = ref(null);
const portalLayer = acquireModalPortalLayer(); const portalLayer = acquireModalPortalLayer();
@@ -280,12 +286,11 @@
else if (e.key.toLowerCase() === 'r') rotateCW(); else if (e.key.toLowerCase() === 'r') rotateCW();
else if (e.key === '0') resetTransform(); else if (e.key === '0') resetTransform();
} }
onMounted(() => window.addEventListener('keydown', onKeydown)); useEventListener(window, 'keydown', onKeydown);
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
async function copyImageToClipboard(url) { async function copyImageToClipboard(url) {
if (!url) return; if (!url) return;
const msg = toast.info('Downloading image...'); const msg = toast.info(t('message.image.downloading'));
try { try {
const response = await webApiService.execute({ url, method: 'GET' }); const response = await webApiService.execute({ url, method: 'GET' });
if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) { if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) {
@@ -293,10 +298,10 @@
} }
const blob = await (await fetch(response.data)).blob(); const blob = await (await fetch(response.data)).blob();
await navigator.clipboard.write([new ClipboardItem({ 'image/png': 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) { } catch (error) {
console.error('Error downloading image:', 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 { } finally {
toast.dismiss(msg); toast.dismiss(msg);
} }
@@ -304,7 +309,7 @@
async function downloadAndSaveImage(url, fileName) { async function downloadAndSaveImage(url, fileName) {
if (!url) return; if (!url) return;
const msg = toast.info('Downloading image...'); const msg = toast.info(t('message.image.downloading'));
try { try {
const response = await webApiService.execute({ url, method: 'GET' }); const response = await webApiService.execute({ url, method: 'GET' });
if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) { if (response.status !== 200 || !String(response.data).startsWith('data:image/png')) {
@@ -326,7 +331,7 @@
document.body.removeChild(link); document.body.removeChild(link);
} catch (error) { } catch (error) {
console.error('Error downloading image:', 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 { } finally {
toast.dismiss(msg); toast.dismiss(msg);
} }
+35 -39
View File
@@ -53,22 +53,14 @@
</TooltipWrapper> </TooltipWrapper>
<TooltipWrapper v-if="showHistoryButton" side="top" :content="historyTooltip"> <TooltipWrapper v-if="showHistoryButton" side="top" :content="historyTooltip">
<Button <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" size="icon-sm"
variant="outline" variant="outline"
style="margin-left: 5px"
@click="handleHistory"> @click="handleHistory">
<History class="h-4 w-4" /> <History class="h-4 w-4" />
</Button> </Button>
</TooltipWrapper> </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"> <div v-if="showInstanceInfo" class="flex items-center ml-2">
<TooltipWrapper v-if="instanceInfoState.isValidInstance" side="top"> <TooltipWrapper v-if="instanceInfoState.isValidInstance" side="top">
<template #content> <template #content>
@@ -79,7 +71,7 @@
<template v-if="instanceInfoState.canCloseInstance"> <template v-if="instanceInfoState.canCloseInstance">
<Button <Button
class="mt-1" class="mt-1"
size="sm" size="xs"
:disabled="!!instance?.closedAt" :disabled="!!instance?.closedAt"
@click="closeInstance(resolvedInstanceLocation)"> @click="closeInstance(resolvedInstanceLocation)">
{{ t('dialog.user.info.close_instance') }} {{ t('dialog.user.info.close_instance') }}
@@ -87,11 +79,12 @@
<br /><br /> <br /><br />
</template> </template>
<span> <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> </span>
<br />
<span> <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> </span>
<br /> <br />
<span><span>iOS: </span>{{ instance?.platforms?.ios }}</span> <span><span>iOS: </span>{{ instance?.platforms?.ios }}</span>
@@ -107,32 +100,44 @@
</span> </span>
<span v-if="instance?.users?.length">{{ t('dialog.user.info.instance_users') }}<br /></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"> <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 }} {{ user.displayName }}
</span> </span>
</template> </template>
</div> </div>
</template> </template>
<div class="mr-2 text-muted-foreground"> <div class="mr-1 text-muted-foreground">
<span v-if="resolvedInstanceLocation === locationStore.lastLocation.location"> <span v-if="resolvedInstanceLocation === locationStore.lastLocation.location">
{{ locationStore.lastLocation.playerList.size }}/{{ instance?.capacity }} {{ locationStore.lastLocation.playerList.size }}/{{ instance?.capacity }}
</span> </span>
<span v-else-if="instance?.userCount"> {{ instance.userCount }}/{{ instance?.capacity }} </span> <span v-else-if="instance?.userCount"> {{ instance.userCount }}/{{ instance?.capacity }} </span>
</div> </div>
</TooltipWrapper> </TooltipWrapper>
<span v-if="friendcount" class="ml-1 flex items-center text-muted-foreground" <TooltipWrapper v-if="friendcount" side="top" :content="t('dialog.user.info.instance_friends_tooltip')">
><UsersRound />{{ friendcount }}</span <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"> <span v-if="instanceInfoState.isValidInstance && !instance?.hasCapacityForYou" class="ml-1">
{{ t('dialog.user.info.instance_full') }} {{ t('dialog.user.info.instance_full') }}
</span> </span>
<span v-if="instance?.queueSize" class="ml-1"> <span v-if="instance?.queueSize" class="ml-1">
{{ t('dialog.user.info.instance_queue') }} {{ instance.queueSize }} {{ t('dialog.user.info.instance_queue') }} {{ instance.queueSize }}
</span> </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') }} {{ t('dialog.user.info.instance_age_gated') }}
</span> </Badge>
</div> </div>
</div> </div>
</template> </template>
@@ -140,6 +145,7 @@
<script setup> <script setup>
import { History, Loader2, LogIn, Mail, MapPin, RefreshCw, UsersRound } from 'lucide-vue-next'; import { History, Loader2, LogIn, Mail, MapPin, RefreshCw, UsersRound } from 'lucide-vue-next';
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
@@ -154,8 +160,10 @@
useModalStore, useModalStore,
useUserStore useUserStore
} from '../stores'; } 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 { instanceRequest, miscRequest } from '../api';
import { showUserDialog } from '../coordinators/userCoordinator';
defineOptions({ defineOptions({
inheritAttrs: false inheritAttrs: false
@@ -174,6 +182,7 @@
const { instanceJoinHistory } = storeToRefs(instanceStore); const { instanceJoinHistory } = storeToRefs(instanceStore);
const { canOpenInstanceInGame } = storeToRefs(inviteStore); const { canOpenInstanceInGame } = storeToRefs(inviteStore);
const { isOpeningInstance } = storeToRefs(launchStore); const { isOpeningInstance } = storeToRefs(launchStore);
const { checkCanInviteSelf } = useInviteChecks();
const props = defineProps({ const props = defineProps({
location: { location: {
@@ -262,7 +271,7 @@
const showLaunchButton = computed(() => props.showLaunch && checkCanInviteSelf(resolvedLaunchLocation.value)); const showLaunchButton = computed(() => props.showLaunch && checkCanInviteSelf(resolvedLaunchLocation.value));
const showInviteYourself = computed(() => props.showInvite && checkCanInviteSelf(resolvedInviteLocation.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 showRefreshButton = computed(() => props.showRefresh && typeof props.onRefresh === 'function');
const showHistoryButton = computed(() => props.showHistory && typeof props.onHistory === 'function'); const showHistoryButton = computed(() => props.showHistory && typeof props.onHistory === 'function');
@@ -305,7 +314,7 @@
shortName: props.shortname shortName: props.shortname
}) })
.then((args) => { .then((args) => {
toast.success('Self invite sent'); toast.success(t('message.invite.self_sent'));
return args; return args;
}); });
}; };
@@ -347,15 +356,11 @@
} }
}; };
const showUserDialog = (userId) => {
userStore.showUserDialog(userId);
};
const closeInstance = (location) => { const closeInstance = (location) => {
modalStore modalStore
.confirm({ .confirm({
description: 'Continue? X Instance, nobody will be able to join', description: t('confirm.close_instance'),
title: 'Confirm' title: t('confirm.title')
}) })
.then(async ({ ok }) => { .then(async ({ ok }) => {
if (!ok) return; if (!ok) return;
@@ -374,12 +379,3 @@
immediate: true immediate: true
}); });
</script> </script>
<style scoped>
.inline-block {
display: inline-block;
}
.ml-5 {
margin-left: 5px;
}
</style>
+172 -81
View File
@@ -1,33 +1,57 @@
<template> <template>
<div class="cursor-pointer"> <component :is="enableContextMenu ? ContextMenu : Passthrough">
<div v-if="!text" class="transparent">-</div> <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"> <div v-show="text" class="flex items-center">
<div v-if="region" :class="['flags', 'mr-1.5', region]"></div> <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"> <TooltipWrapper :content="tooltipContent" :disabled="tooltipDisabled" :delay-duration="300" side="top">
<div <div
:class="locationClasses" :class="locationClasses"
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden" class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden truncate"
@click="handleShowWorldDialog"> @click="handleShowWorldDialog">
<Spinner v-if="isTraveling" class="mr-1" /> <Spinner v-if="isTraveling" class="mr-1 shrink-0" />
<span class="min-w-0 truncate">{{ text }}</span> <span class="min-w-0 flex-1 truncate">
<span v-if="showInstanceIdInLocation && instanceName" class="ml-1 whitespace-nowrap">{{ <span>{{ text }}</span>
<span v-if="showInstanceIdInLocation && instanceName" class="ml-1">{{
` · #${instanceName}` ` · #${instanceName}`
}}</span> }}</span>
<span <span v-if="groupName" class="ml-0.5 cursor-pointer" @click.stop="handleShowGroupDialog">
v-if="groupName"
class="ml-0.5 whitespace-nowrap cursor-pointer"
@click.stop="handleShowGroupDialog">
({{ groupName }}) ({{ groupName }})
</span> </span>
</span>
</div> </div>
</TooltipWrapper> </TooltipWrapper>
<TooltipWrapper v-if="isClosed" :content="closedTooltip" :disabled="disableTooltip"> <TooltipWrapper v-if="isClosed" :content="closedTooltip" :disabled="disableTooltip">
<AlertTriangle class="inline-block ml-2 text-muted-foreground" /> <AlertTriangle class="inline-block ml-2 text-muted-foreground shrink-0" />
</TooltipWrapper> </TooltipWrapper>
<Lock v-if="strict" class="inline-block ml-2 text-muted-foreground" /> <Lock v-if="strict" class="inline-block ml-2 text-muted-foreground shrink-0" />
</template>
</div> </div>
</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> </template>
<script setup> <script setup>
@@ -37,25 +61,43 @@
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { import {
useAppearanceSettingsStore, ContextMenu,
useGroupStore, ContextMenuContent,
useInstanceStore, ContextMenuTrigger
useSearchStore, } from './ui/context-menu';
useWorldStore
} from '../stores'; import {
import { getGroupName, getWorldName, parseLocation } from '../shared/utils'; 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 { Spinner } from './ui/spinner';
import WorldActionMenuItems from './WorldActionMenuItems.vue';
import { accessTypeLocaleKeyMap } from '../shared/constants'; import { accessTypeLocaleKeyMap } from '../shared/constants';
defineOptions({
inheritAttrs: false
});
const Passthrough = (_, { slots }) => slots.default?.();
const { t } = useI18n(); const { t } = useI18n();
const { cachedWorlds, showWorldDialog } = useWorldStore(); const { cachedWorlds } = useWorldStore();
const { showGroupDialog } = useGroupStore();
const { showPreviousInstancesInfoDialog } = useInstanceStore(); const { showPreviousInstancesInfoDialog } = useInstanceStore();
const { verifyShortName } = useSearchStore(); const { verifyShortName } = useSearchStore();
const { cachedInstances } = useInstanceStore(); const { cachedInstances } = useInstanceStore();
const { lastInstanceApplied } = storeToRefs(useInstanceStore()); const { lastInstanceApplied } = storeToRefs(useInstanceStore());
const { showInstanceIdInLocation } = storeToRefs(useAppearanceSettingsStore()); const { canOpenInstanceInGame } = useInviteStore();
const { showInstanceIdInLocation, isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
const props = defineProps({ const props = defineProps({
location: String, location: String,
@@ -79,17 +121,24 @@
isOpenPreviousInstanceInfoDialog: { isOpenPreviousInstanceInfoDialog: {
type: Boolean, type: Boolean,
default: false default: false
},
enableContextMenu: {
type: Boolean,
default: false
} }
}); });
const text = ref(''); const text = ref('');
const region = ref(''); const region = ref('');
const strict = ref(false); const strict = ref(false);
const ageGate = ref(false);
const isTraveling = ref(false); const isTraveling = ref(false);
const parsedLocation = ref({ isRealInstance: false, worldId: '', tag: '', shortName: '' });
const groupName = ref(''); const groupName = ref('');
const isClosed = ref(false); const isClosed = ref(false);
const instanceName = ref(''); const instanceName = ref('');
const isAgeRestricted = computed(() => !isAgeGatedInstancesVisible.value && ageGate.value);
const isLocationLink = computed(() => props.link && props.location !== 'private' && props.location !== 'offline'); const isLocationLink = computed(() => props.link && props.location !== 'private' && props.location !== 'offline');
const locationClasses = computed(() => [ const locationClasses = computed(() => [
'x-location', 'x-location',
@@ -98,9 +147,7 @@
} }
]); ]);
const tooltipContent = computed(() => `${t('dialog.new_instance.instance_id')}: #${instanceName.value}`); const tooltipContent = computed(() => `${t('dialog.new_instance.instance_id')}: #${instanceName.value}`);
const tooltipDisabled = computed( const tooltipDisabled = computed(() => props.disableTooltip || !instanceName.value || showInstanceIdInLocation.value);
() => props.disableTooltip || !instanceName.value || showInstanceIdInLocation.value
);
const closedTooltip = computed(() => t('dialog.user.info.instance_closed')); const closedTooltip = computed(() => t('dialog.user.info.instance_closed'));
let isDisposed = false; let isDisposed = false;
@@ -119,6 +166,9 @@
} }
); );
/**
*
*/
function currentInstanceId() { function currentInstanceId() {
if (typeof props.traveling !== 'undefined' && props.location === 'traveling') { if (typeof props.traveling !== 'undefined' && props.location === 'traveling') {
return props.traveling; return props.traveling;
@@ -126,16 +176,23 @@
return props.location; return props.location;
} }
/**
*
*/
function resetState() { function resetState() {
text.value = ''; text.value = '';
region.value = ''; region.value = '';
strict.value = false; strict.value = false;
ageGate.value = false;
isTraveling.value = false; isTraveling.value = false;
groupName.value = ''; groupName.value = '';
isClosed.value = false; isClosed.value = false;
instanceName.value = ''; instanceName.value = '';
} }
/**
*
*/
function parse() { function parse() {
if (isDisposed) { if (isDisposed) {
return; return;
@@ -148,6 +205,7 @@
isTraveling.value = true; isTraveling.value = true;
} }
const L = parseLocation(instanceId); const L = parseLocation(instanceId);
parsedLocation.value = L;
setText(L); setText(L);
instanceName.value = L.instanceName; instanceName.value = L.instanceName;
if (!L.isRealInstance) { if (!L.isRealInstance) {
@@ -158,8 +216,13 @@
updateGroupName(L, instanceId); updateGroupName(L, instanceId);
updateRegion(L); updateRegion(L);
strict.value = L.strict; strict.value = L.strict;
ageGate.value = L.ageGate;
} }
/**
*
* @param L
*/
function applyInstanceRef(L) { function applyInstanceRef(L) {
const instanceRef = cachedInstances.get(L.tag); const instanceRef = cachedInstances.get(L.tag);
if (typeof instanceRef === 'undefined') { if (typeof instanceRef === 'undefined') {
@@ -174,6 +237,11 @@
} }
} }
/**
*
* @param L
* @param instanceId
*/
function updateGroupName(L, instanceId) { function updateGroupName(L, instanceId) {
if (props.grouphint) { if (props.grouphint) {
groupName.value = props.grouphint; groupName.value = props.grouphint;
@@ -190,68 +258,55 @@
}); });
} }
/**
*
* @param L
*/
function updateRegion(L) { function updateRegion(L) {
region.value = ''; region.value = resolveRegion(L);
if (!L.isOffline && !L.isPrivate && !L.isTraveling) {
region.value = L.region;
if (!L.region && L.instanceId) {
region.value = 'us';
}
}
} }
/**
*
* @param accessTypeName
*/
function getAccessTypeLabel(accessTypeName) {
return translateAccessType(accessTypeName, t, accessTypeLocaleKeyMap);
}
/**
*
* @param L
*/
function setText(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 = getLocationText(L, {
text.value = 'Offline'; hint: props.hint,
} else if (L.isPrivate) { worldName,
text.value = 'Private'; accessTypeLabel,
} else if (L.isTraveling) { t
text.value = 'Traveling'; });
} else if (typeof props.hint === 'string' && props.hint !== '') {
if (L.instanceId) { if (L.worldId && typeof cachedRef === 'undefined') {
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) => { getWorldName(L.worldId).then((name) => {
if (!isDisposed && name && currentInstanceId() === L.tag) { if (!isDisposed && name && currentInstanceId() === L.tag) {
if (L.instanceId) { text.value = getLocationText(L, {
text.value = `${name} · ${translateAccessType(L.accessTypeName)}`; hint: props.hint,
} else { worldName: name,
text.value = name; accessTypeLabel: getAccessTypeLabel(L.accessTypeName),
} t
});
} }
}); });
} else if (L.instanceId) {
text.value = `${ref.name} · ${accessTypeLabel}`;
} else {
text.value = ref.name;
}
} }
} }
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() { function handleShowWorldDialog() {
if (props.link) { if (props.link) {
let instanceId = currentInstanceId(); let instanceId = currentInstanceId();
@@ -267,6 +322,9 @@
} }
} }
/**
*
*/
function handleShowGroupDialog() { function handleShowGroupDialog() {
let location = currentInstanceId(); let location = currentInstanceId();
if (!location) { if (!location) {
@@ -278,10 +336,43 @@
} }
showGroupDialog(L.groupId); 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>
+19 -11
View File
@@ -5,11 +5,11 @@
<Unlock v-if="isUnlocked" :class="['inline-block', 'mr-1.25']" /> <Unlock v-if="isUnlocked" :class="['inline-block', 'mr-1.25']" />
<span> {{ accessTypeName }} #{{ instanceName }}</span> <span> {{ accessTypeName }} #{{ instanceName }}</span>
</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')"> <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> </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> </span>
</template> </template>
@@ -20,6 +20,7 @@
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useGroupStore, useInstanceStore, useLaunchStore } from '../stores'; import { useGroupStore, useInstanceStore, useLaunchStore } from '../stores';
import { showGroupDialog } from '../coordinators/groupCoordinator';
import { getGroupName, parseLocation } from '../shared/utils'; import { getGroupName, parseLocation } from '../shared/utils';
import { accessTypeLocaleKeyMap } from '../shared/constants'; import { accessTypeLocaleKeyMap } from '../shared/constants';
@@ -50,6 +51,9 @@
const groupName = ref(''); const groupName = ref('');
const isClosed = ref(false); const isClosed = ref(false);
/**
*
*/
function parse() { function parse() {
const locObj = props.locationobject; const locObj = props.locationobject;
location.value = locObj.tag; location.value = locObj.tag;
@@ -94,6 +98,10 @@
} }
} }
/**
*
* @param accessTypeNameRaw
*/
function translateAccessType(accessTypeNameRaw) { function translateAccessType(accessTypeNameRaw) {
const key = accessTypeLocaleKeyMap[accessTypeNameRaw]; const key = accessTypeLocaleKeyMap[accessTypeNameRaw];
if (!key) { if (!key) {
@@ -118,20 +126,20 @@
{ immediate: true } { immediate: true }
); );
/**
*
*/
function showLaunchDialog() { function showLaunchDialog() {
launchStore.showLaunchDialog(location.value, shortName.value); launchStore.showLaunchDialog(location.value, shortName.value);
} }
function showGroupDialog() { /**
*
*/
function openLocationGroupDialog() {
if (!location.value) return; if (!location.value) return;
const L = parseLocation(location.value); const L = parseLocation(location.value);
if (!L.groupId) return; if (!L.groupId) return;
groupStore.showGroupDialog(L.groupId); showGroupDialog(L.groupId);
} }
</script> </script>
<style scoped>
.inline-block {
display: inline-block;
}
</style>
-790
View File
@@ -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>
+8 -3
View File
@@ -2,6 +2,9 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
@@ -59,8 +62,8 @@
<Popover> <Popover>
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button variant="outline" size="sm" class="flex items-center gap-2 px-2" :disabled="disabled"> <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="h-4 w-4 rounded" :style="{ backgroundColor: safeValue }" />
<span class="font-mono text-xs opacity-80"> <span class="text-xs opacity-80">
{{ displayText }} {{ displayText }}
</span> </span>
@@ -93,7 +96,9 @@
@input="onInput" /> @input="onInput" />
<div v-if="clearable" class="mt-3 flex justify-end"> <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> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
+267
View File
@@ -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>
+63
View File
@@ -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>
+717
View File
@@ -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>
+3 -12
View File
@@ -2,7 +2,8 @@
<span>{{ text }}</span> <span>{{ text }}</span>
</template> </template>
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { useNow } from '@vueuse/core';
import { computed } from 'vue';
import { timeToText } from '../shared/utils'; import { timeToText } from '../shared/utils';
@@ -13,18 +14,8 @@
} }
}); });
const now = ref(Date.now()); const now = useNow({ interval: 15000 });
const text = computed(() => { const text = computed(() => {
return props.epoch ? timeToText(now.value - props.epoch) : '-'; return props.epoch ? timeToText(now.value - props.epoch) : '-';
}); });
let timerId = null;
onMounted(() => {
timerId = setInterval(() => {
now.value = Date.now();
}, 15000);
});
onBeforeUnmount(() => {
clearInterval(timerId);
});
</script> </script>
+165
View File
@@ -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>
+112
View File
@@ -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>
+224
View File
@@ -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();
});
});
});
+118
View File
@@ -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');
});
});
+119
View File
@@ -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);
});
});
+401
View File
@@ -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);
});
});
+293
View File
@@ -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');
});
});
+75
View File
@@ -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> <template>
<Dialog <Dialog
:open="setAvatarStylesDialog.visible" :open="setAvatarStylesDialog.visible"
@update:open="(open) => { @update:open="
(open) => {
if (!open) closeSetAvatarStylesDialog(); if (!open) closeSetAvatarStylesDialog();
}"> }
">
<DialogContent class="x-dialog sm:max-w-100"> <DialogContent class="x-dialog sm:max-w-100">
<DialogHeader> <DialogHeader>
<DialogTitle>{{ t('dialog.set_avatar_styles.header') }}</DialogTitle> <DialogTitle>{{ t('dialog.set_avatar_styles.header') }}</DialogTitle>
@@ -56,15 +58,14 @@
<br /> <br />
<div style="font-size: 12px">{{ t('dialog.set_world_tags.author_tags') }}</div> <div class="text-xs">{{ t('dialog.set_world_tags.author_tags') }}</div>
<InputGroupTextareaField <InputGroupTextareaField
:model-value="setAvatarStylesDialog.authorTags" :model-value="setAvatarStylesDialog.authorTags"
:autosize="{ minRows: 2, maxRows: 5 }" :autosize="{ minRows: 2, maxRows: 5 }"
:rows="2" :rows="2"
placeholder="" placeholder=""
style="margin-top: 10px" input-class="resize-none mt-2"
input-class="resize-none"
@update:modelValue="(v) => updateDialog({ authorTags: v })" /> @update:modelValue="(v) => updateDialog({ authorTags: v })" />
</template> </template>
@@ -81,17 +82,18 @@
</template> </template>
<script setup> <script setup>
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { InputGroupTextareaField } from '@/components/ui/input-group'; import { InputGroupTextareaField } from '@/components/ui/input-group';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { watch } from 'vue'; import { watch } from 'vue';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select';
import { avatarRequest, queryRequest } from '../../../api';
import { arraysMatch } from '../../../shared/utils'; import { arraysMatch } from '../../../shared/utils';
import { avatarRequest } from '../../../api';
import { useAvatarStore } from '../../../stores'; import { useAvatarStore } from '../../../stores';
import { applyAvatar } from '../../../coordinators/avatarCoordinator';
const props = defineProps({ const props = defineProps({
setAvatarStylesDialog: { setAvatarStylesDialog: {
@@ -103,7 +105,6 @@
const emit = defineEmits(['update:setAvatarStylesDialog']); const emit = defineEmits(['update:setAvatarStylesDialog']);
const { t } = useI18n(); const { t } = useI18n();
const { applyAvatar } = useAvatarStore();
const SELECT_CLEAR_VALUE = '__clear__'; const SELECT_CLEAR_VALUE = '__clear__';
@@ -116,6 +117,10 @@
} }
); );
/**
*
* @param patch
*/
function updateDialog(patch) { function updateDialog(patch) {
emit('update:setAvatarStylesDialog', { emit('update:setAvatarStylesDialog', {
...props.setAvatarStylesDialog, ...props.setAvatarStylesDialog,
@@ -123,9 +128,12 @@
}); });
} }
/**
*
*/
async function getAvatarStyles() { async function getAvatarStyles() {
try { try {
const ref = await avatarRequest.getAvailableAvatarStyles(); const ref = await queryRequest.fetch('avatarStyles');
const styles = []; const styles = [];
const stylesMap = new Map(); const stylesMap = new Map();
for (const style of ref.json) { for (const style of ref.json) {
@@ -142,10 +150,16 @@
} }
} }
/**
*
*/
function closeSetAvatarStylesDialog() { function closeSetAvatarStylesDialog() {
updateDialog({ visible: false }); updateDialog({ visible: false });
} }
/**
*
*/
function saveSetAvatarStylesDialog() { function saveSetAvatarStylesDialog() {
const primaryStyleId = const primaryStyleId =
props.setAvatarStylesDialog.availableAvatarStylesMap.get(props.setAvatarStylesDialog.primaryStyle) || ''; props.setAvatarStylesDialog.availableAvatarStylesMap.get(props.setAvatarStylesDialog.primaryStyle) || '';
@@ -199,5 +213,3 @@
}); });
} }
</script> </script>
<style scoped></style>
@@ -43,7 +43,7 @@
v-model="setAvatarTagsDialog.selectedTagsCsv" v-model="setAvatarTagsDialog.selectedTagsCsv"
:rows="2" :rows="2"
:placeholder="t('dialog.set_avatar_tags.custom_tags_placeholder')" :placeholder="t('dialog.set_avatar_tags.custom_tags_placeholder')"
style="margin-top: 10px" class="mt-2"
input-class="resize-none" input-class="resize-none"
@input="updateInputAvatarTags" /> @input="updateInputAvatarTags" />
<br /> <br />
@@ -59,23 +59,32 @@
t('dialog.set_avatar_tags.select_all') t('dialog.set_avatar_tags.select_all')
}}</Button> }}</Button>
</template> </template>
<span style="margin-left: 5px" <span style="margin-left: 6px"
>{{ props.setAvatarTagsDialog.selectedAvatarIds.length }} / >{{ props.setAvatarTagsDialog.selectedAvatarIds.length }} /
{{ setAvatarTagsDialog.ownAvatars.length }}</span {{ setAvatarTagsDialog.ownAvatars.length }}</span
> >
<Loader2 v-if="setAvatarTagsDialog.loading" class="is-loading" style="margin-left: 5px" /> <Spinner v-if="setAvatarTagsDialog.loading" class="inline-block ml-2" />
<br /> <br />
<div class="x-friend-list" style="margin-top: 10px; min-height: 60px; max-height: 280px"> <div
class="flex flex-wrap items-start max-h-[300px] overflow-hidden auto"
style="margin-top: 8px; min-height: 60px">
<div <div
v-for="avatar in setAvatarTagsDialog.ownAvatars" v-for="avatar in setAvatarTagsDialog.ownAvatars"
:key="avatar.id" :key="avatar.id"
:class="['item-width', 'x-friend-item', 'x-friend-item-border']" :class="[
'w-[335px]',
'box-border flex items-center p-1.5 text-[13px] cursor-pointer hover:rounded-[25px_5px_5px_25px]'
]"
@click="showAvatarDialog(avatar.id)"> @click="showAvatarDialog(avatar.id)">
<div class="avatar"> <div class="relative inline-block flex-none size-9 mr-2.5">
<img v-if="avatar.thumbnailImageUrl" :src="avatar.thumbnailImageUrl" loading="lazy" /> <img
v-if="avatar.thumbnailImageUrl"
class="size-full rounded-full object-cover"
:src="avatar.thumbnailImageUrl"
loading="lazy" />
</div> </div>
<div class="detail"> <div class="flex-1 overflow-hidden">
<span class="name" v-text="avatar.name"></span> <span class="block truncate font-medium leading-[18px]" v-text="avatar.name"></span>
<span <span
v-if="avatar.releaseStatus === 'public'" v-if="avatar.releaseStatus === 'public'"
class="block truncate text-xs" class="block truncate text-xs"
@@ -87,7 +96,7 @@
<span v-else class="block truncate text-xs" v-text="avatar.releaseStatus"></span> <span v-else class="block truncate text-xs" v-text="avatar.releaseStatus"></span>
<span class="block truncate text-xs" v-text="avatarTagStrings.get(avatar.id)"></span> <span class="block truncate text-xs" v-text="avatarTagStrings.get(avatar.id)"></span>
</div> </div>
<Button size="sm" variant="ghost" style="margin-left: 5px" @click.stop> <Button size="sm" variant="ghost" style="margin-left: 6px" @click.stop>
<Checkbox <Checkbox
:model-value="props.setAvatarTagsDialog.selectedAvatarIds.includes(avatar.id)" :model-value="props.setAvatarTagsDialog.selectedAvatarIds.includes(avatar.id)"
@update:modelValue="(val) => toggleAvatarSelection(avatar.id, val)" /> @update:modelValue="(val) => toggleAvatarSelection(avatar.id, val)" />
@@ -111,7 +120,7 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { InputGroupTextareaField } from '@/components/ui/input-group'; import { InputGroupTextareaField } from '@/components/ui/input-group';
import { Loader2 } from 'lucide-vue-next'; import { Spinner } from '@/components/ui/spinner';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { watch } from 'vue'; import { watch } from 'vue';
@@ -119,8 +128,8 @@
import { avatarRequest } from '../../../api'; import { avatarRequest } from '../../../api';
import { removeFromArray } from '../../../shared/utils'; import { removeFromArray } from '../../../shared/utils';
import { useAvatarStore } from '../../../stores'; import { useAvatarStore } from '../../../stores';
import { showAvatarDialog, applyAvatar } from '../../../coordinators/avatarCoordinator';
const { showAvatarDialog, applyAvatar } = useAvatarStore();
const { cachedAvatars } = useAvatarStore(); const { cachedAvatars } = useAvatarStore();
const { t } = useI18n(); const { t } = useI18n();
@@ -145,6 +154,9 @@
} }
); );
/**
*
*/
function closeSetAvatarTagsDialog() { function closeSetAvatarTagsDialog() {
emit('update:setAvatarTagsDialog', { emit('update:setAvatarTagsDialog', {
...props.setAvatarTagsDialog, ...props.setAvatarTagsDialog,
@@ -152,6 +164,9 @@
}); });
} }
/**
*
*/
function updateSelectedAvatarTags() { function updateSelectedAvatarTags() {
const D = props.setAvatarTagsDialog; const D = props.setAvatarTagsDialog;
if (D.contentHorror) { if (D.contentHorror) {
@@ -193,6 +208,11 @@
D.selectedTagsCsv = D.selectedTags.join(',').replace(/content_/g, ''); D.selectedTagsCsv = D.selectedTags.join(',').replace(/content_/g, '');
} }
/**
*
* @param avatarId
* @param checked
*/
function toggleAvatarSelection(avatarId, checked) { function toggleAvatarSelection(avatarId, checked) {
const D = props.setAvatarTagsDialog; const D = props.setAvatarTagsDialog;
const isSelected = D.selectedAvatarIds.includes(avatarId); const isSelected = D.selectedAvatarIds.includes(avatarId);
@@ -204,6 +224,9 @@
} }
} }
/**
*
*/
function updateAvatarTagsString() { function updateAvatarTagsString() {
const D = props.setAvatarTagsDialog; const D = props.setAvatarTagsDialog;
for (const ref of D.ownAvatars) { for (const ref of D.ownAvatars) {
@@ -229,6 +252,9 @@
} }
} }
/**
*
*/
function setAvatarTagsSelectToggle() { function setAvatarTagsSelectToggle() {
const D = props.setAvatarTagsDialog; const D = props.setAvatarTagsDialog;
const allSelected = D.ownAvatars.length === D.selectedAvatarIds.length; const allSelected = D.ownAvatars.length === D.selectedAvatarIds.length;
@@ -243,6 +269,9 @@
} }
} }
/**
*
*/
async function saveSetAvatarTagsDialog() { async function saveSetAvatarTagsDialog() {
const D = props.setAvatarTagsDialog; const D = props.setAvatarTagsDialog;
if (D.loading) { if (D.loading) {
@@ -278,6 +307,9 @@
} }
} }
/**
*
*/
function updateInputAvatarTags() { function updateInputAvatarTags() {
const D = props.setAvatarTagsDialog; const D = props.setAvatarTagsDialog;
D.contentHorror = false; D.contentHorror = false;
@@ -311,9 +343,3 @@
} }
} }
</script> </script>
<style scoped>
.item-width {
width: 335px;
}
</style>

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