VRChat Web Pages Extender

Add features into VRChat Web Pages and improve user experience.

目前为 2023-09-28 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name VRChat Web Pages Extender
  3. // @name:ja VRChat Webページ拡張
  4. // @description Add features into VRChat Web Pages and improve user experience.
  5. // @description:ja VRChatのWebページに機能を追加し、また使い勝手を改善します。
  6. // @namespace https://greasyfork.org/users/137
  7. // @version 2.18.0
  8. // @match https://vrchat.com/home
  9. // @match https://vrchat.com/home?*
  10. // @match https://vrchat.com/home#*
  11. // @match https://vrchat.com/home/*
  12. // @require https://greasyfork.org/scripts/19616/code/utilities.js?version=895049
  13. // @license MPL-2.0
  14. // @contributionURL https://pokemori.booth.pm/items/969835
  15. // @compatible Edge
  16. // @compatible Firefox Firefoxを推奨 / Firefox is recommended
  17. // @compatible Opera
  18. // @compatible Chrome
  19. // @grant dummy
  20. // @run-at document-start
  21. // @icon https://images.squarespace-cdn.com/content/v1/5f0770791aaf57311515b23d/1599678606410-4QMTB25DHF87E8EFFKXY/ke17ZwdGBToddI8pDm48kGfiFqkITS6axXxhYYUCnlRZw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpxQ1ibo-zdhORxWnJtmNCajDe36aQmu-4Z4SFOss0oowgxUaachD66r8Ra2gwuBSqM/favicon.ico
  22. // @author 100の人
  23. // @homepageURL https://greasyfork.org/scripts/371331
  24. // ==/UserScript==
  25.  
  26. /*global Gettext, _, h, GreasemonkeyUtils */
  27.  
  28. 'use strict';
  29.  
  30. // L10N
  31. Gettext.setLocalizedTexts({
  32. /*eslint-disable quote-props, max-len */
  33. 'en': {
  34. 'エラーが発生しました': 'Error occurred',
  35. },
  36. /*eslint-enable quote-props, max-len */
  37. });
  38.  
  39. Gettext.setLocale(navigator.language);
  40.  
  41.  
  42.  
  43. if (typeof content !== 'undefined') {
  44. // For Greasemonkey 4
  45. fetch = content.fetch.bind(content); //eslint-disable-line no-global-assign, no-native-reassign, no-undef
  46. }
  47.  
  48.  
  49.  
  50. /**
  51. * ページ上部にエラー内容を表示します。
  52. * @param {Error} exception
  53. * @returns {void}
  54. */
  55. function showError(exception)
  56. {
  57. console.error(exception);
  58. try {
  59. const errorMessage = _('エラーが発生しました') + ': ' + exception
  60. + ('stack' in exception ? '\n\n' + exception.stack : '');
  61. const homeContent = document.getElementsByClassName('home-content')[0];
  62. if (homeContent) {
  63. homeContent.firstElementChild.firstElementChild.insertAdjacentHTML('afterbegin', h`<div class="row">
  64. <div class="alert alert-danger fade show" role="alert"
  65. style="white-space: pre-wrap; font-size: 1rem; font-weight: normal;">${errorMessage}</div>
  66. </div>`);
  67. } else {
  68. alert(errorMessage); //eslint-disable-line no-alert
  69. }
  70. } catch (e) {
  71. alert(_('エラーが発生しました') + ': ' + e); //eslint-disable-line no-alert
  72. }
  73. }
  74.  
  75. const ID = 'vrchat-web-pages-extender-137';
  76.  
  77. /**
  78. * 一度に取得できる最大の要素数。
  79. * @constant {number}
  80. */
  81. const MAX_ITEMS_COUNT = 100;
  82.  
  83. /**
  84. * Statusの種類。
  85. * @constant {number}
  86. */
  87. const STATUSES = {
  88. 'join me': {
  89. label: 'Join Me: Auto-accept join requests.',
  90. color: '--status-joinme',
  91. },
  92. active: {
  93. label: 'Online: See join requests.',
  94. color: '--status-online',
  95. },
  96. 'ask me': {
  97. label: 'Ask Me: Hide location, see join requests.',
  98. color: '--status-askme',
  99. },
  100. busy: {
  101. label: 'Do Not Disturb: Hide location, hide join requests.',
  102. color: '--status-busy',
  103. },
  104. };
  105.  
  106. /**
  107. * 一つのブックマークグループの最大登録数。
  108. * @constant {Object.<number>}
  109. */
  110. const MAX_FRIEND_FAVORITE_COUNT_PER_GROUP = 150;
  111.  
  112. /**
  113. * @type {Function}
  114. * @access private
  115. */
  116. let setUserDetails;
  117.  
  118. /**
  119. * @type {Promise.<Object>}
  120. * @access private
  121. */
  122. let userDetails = new Promise(function (resolve) {
  123. let settled = false;
  124. setUserDetails = function (details) {
  125. if (settled) {
  126. userDetails = Promise.resolve(details);
  127. } else {
  128. settled = true;
  129. resolve(details);
  130. }
  131. };
  132. });
  133.  
  134. /**
  135. * キーにワールドIDを持つ連想配列。
  136. * @type {Object.<(string|string[]|boolean|number|Object.<(string|number)>[]|(string|number)[][]|null)>}
  137. */
  138. const worlds = { };
  139.  
  140. /**
  141. * キーにグループIDを持つ連想配列。
  142. * @type {Object.<string,(string|string[]|number|boolean|Object.<string,(string|string[]|boolean)?>)?>[]}
  143. */
  144. const groups = { };
  145.  
  146. addEventListener('message', function (event) {
  147. if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null
  148. || event.data.id !== ID) {
  149. return;
  150. }
  151.  
  152. if (event.data.userDetails) {
  153. setUserDetails(event.data.userDetails);
  154. } else if (event.data.world) {
  155. worlds[event.data.world.id] = event.data.world;
  156. const locations = document.getElementsByClassName('locations')[0];
  157. if (!locations) {
  158. return;
  159. }
  160. for (const [ instanceId ] of event.data.world.instances) {
  161. const locationLink = locations.querySelector(`.locations
  162. [href*=${CSS.escape(`/home/launch?worldId=${event.data.world.id}&instanceId=${instanceId}`)}]`);
  163. if (!locationLink) {
  164. continue;
  165. }
  166. insertInstanceUserCountAndCapacity(locationLink.closest('.locations > *'), event.data.world.id, instanceId);
  167. }
  168. } else if (event.data.group) {
  169. groups[event.data.group.id] = event.data.group;
  170. }
  171. });
  172.  
  173. /**
  174. * ログインしているユーザーの情報を取得します。
  175. * @see [User Info — VRChat API Documentation]{@link https://vrchatapi.github.io/#/UserAPI/CurrentUserDetails}
  176. * @returns {Promise.<?Object.<(string|string[]|boolean|number|Object)>>}
  177. */
  178. async function getUserDetails()
  179. {
  180. return await userDetails;
  181. }
  182.  
  183. /**
  184. * JSONファイルをオブジェクトとして取得します。
  185. * @param {string} url
  186. * @returns {Promise.<(Object|Array)>} OKステータスでなければ失敗します。
  187. */
  188. async function fetchJSON(url)
  189. {
  190. const response = await fetch(url, {credentials: 'same-origin'});
  191. return response.ok
  192. ? response.json()
  193. : Promise.reject(new Error(`${response.status} ${response.statusText}\n${await response.text()}`));
  194. }
  195.  
  196. let friendFavoriteGroupNameDisplayNamePairs;
  197.  
  198. /**
  199. * ログインしているユーザーのフレンドfavoriteのグループ名を取得します。
  200. * @returns {Promise.<Object.<string>[]>}
  201. */
  202. function getFriendFavoriteGroupNameDisplayNamePairs()
  203. {
  204. if (!friendFavoriteGroupNameDisplayNamePairs) {
  205. friendFavoriteGroupNameDisplayNamePairs
  206. = fetchJSON('/api/1/favorite/groups?type=friend', {credentials: 'same-origin'}).then(function (groups) {
  207. const groupNameDisplayNamePairs = {};
  208. for (const group of groups) {
  209. groupNameDisplayNamePairs[group.name] = group.displayName;
  210. }
  211. return groupNameDisplayNamePairs;
  212. });
  213. }
  214. return friendFavoriteGroupNameDisplayNamePairs;
  215. }
  216.  
  217. /**
  218. * @type {Promise.<Object.<(string|string[])>[]>}
  219. * @access private
  220. */
  221. let friendFavoritesPromise;
  222.  
  223. /**
  224. * ブックマークを全件取得します。
  225. * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites}
  226. * @returns {Promise.<Object.<(string|string[])>[]>}
  227. */
  228. function getFriendFavorites()
  229. {
  230. return friendFavoritesPromise || (friendFavoritesPromise = async function () {
  231. const allFavorites = [];
  232. let offset = 0;
  233.  
  234. while (true) { //eslint-disable-line no-constant-condition
  235. const favorites = await fetchJSON(
  236. `/api/1/favorites/?type=friend&n=${MAX_ITEMS_COUNT}&offset=${offset}`,
  237. ).catch(showError);
  238.  
  239. allFavorites.push(...favorites);
  240.  
  241. if (favorites.length < MAX_ITEMS_COUNT) {
  242. break;
  243. }
  244.  
  245. offset += favorites.length;
  246. }
  247. return allFavorites;
  248. }());
  249. }
  250.  
  251. /**
  252. * 自分のユーザーページの編集ダイアログへ、ステータス変更プルダウンメニューを挿入、ステータスメッセージ入力欄へ履歴を追加します。
  253. * @returns {Promise.<void>}
  254. */
  255. async function insertSelectStatusAndStatusMessageHistory()
  256. {
  257. if (document.getElementById('select-status')) {
  258. // すでに挿入済みなら
  259. return;
  260. }
  261.  
  262. const inputStatusMessage = document.getElementById('input-status-message');
  263. if (!inputStatusMessage) {
  264. return;
  265. }
  266.  
  267. const statusMessageCell = inputStatusMessage.parentElement;
  268. const statusRow = statusMessageCell.parentElement;
  269. statusRow.classList.remove('tw-flex-col');
  270. statusRow.classList.add('tw-flex-row', 'tw-gap-4');
  271.  
  272. /** @type {HTMLDivElement} */
  273. const statusCell = statusMessageCell.cloneNode(true);
  274.  
  275. statusMessageCell.classList.add('tw-grow');
  276.  
  277. statusCell.getElementsByTagName('input')[0].remove();
  278. statusRow.prepend(statusCell);
  279. statusCell.insertAdjacentHTML(
  280. 'beforeend',
  281. `<select id="select-status" class="${h(inputStatusMessage.className)}">
  282. ${Object.keys(STATUSES)
  283. .map(status => h`<option value="${status}">⬤ ${STATUSES[status].label}</option>`).join('')}
  284. </select>`,
  285. );
  286. const selectStatus = statusCell.getElementsByTagName('select')[0];
  287. const statusCellLabel = statusCell.getElementsByTagName('label')[0];
  288. statusCellLabel.textContent = 'Status';
  289. statusCellLabel.htmlFor = selectStatus.id;
  290. function updateInputStatusClass() {
  291. for (const status in STATUSES) {
  292. selectStatus
  293. .classList[status === selectStatus.value ? 'add' : 'remove'](status.replace(' ', '-'));
  294. }
  295. }
  296. selectStatus.addEventListener('change', updateInputStatusClass);
  297. selectStatus.value = (await getUserDetails()).status;
  298. updateInputStatusClass();
  299.  
  300. GreasemonkeyUtils.executeOnUnsafeContext(function (selectStatusId) {
  301. const targetURL = location.href.replace('/home/user/', '/api/1/users/');
  302.  
  303. globalThis.Request = new Proxy(globalThis.Request, {
  304. construct(target, argumentList, newTarget)
  305. {
  306. try {
  307. if (argumentList[0] === targetURL) {
  308. /** @type {RequestInit} */
  309. const requestInit = argumentList[1];
  310. if (requestInit?.method === 'PUT' && requestInit?.body) {
  311. const headers = requestInit.headers;
  312. if (typeof headers?.get === 'function'
  313. && headers?.get('content-type') === 'application/json') {
  314. const selectStatus = document.getElementById(selectStatusId);
  315. if (selectStatus) {
  316. requestInit.body = JSON.stringify(Object.assign(JSON.parse(requestInit.body), {
  317. status: selectStatus.value,
  318. }));
  319. }
  320. }
  321. }
  322. }
  323. } catch (exception) {
  324. console.error(exception);
  325. }
  326.  
  327. return Reflect.construct(target, argumentList, newTarget);
  328. },
  329. });
  330. }, [ selectStatus.id ]);
  331.  
  332. // ステータスメッセージ入力欄へ履歴を追加
  333. inputStatusMessage.insertAdjacentHTML('afterend', `<datalist id="input-status-message-history">
  334. ${(await getUserDetails())
  335. .statusHistory.map(statusDescription => h`<option>${statusDescription}</option>`).join('')}
  336. </datalist>`);
  337. inputStatusMessage.setAttribute('list', inputStatusMessage.nextElementSibling.id);
  338. }
  339.  
  340. /**
  341. * フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。
  342. * @returns {Promise.<void>}
  343. */
  344. async function updateFriendFavoriteCounts()
  345. {
  346. const counts = {};
  347. for (const favorite of await getFriendFavorites()) {
  348. for (const tag of favorite.tags) {
  349. if (!(tag in counts)) {
  350. counts[tag] = 0;
  351. }
  352. counts[tag]++;
  353. }
  354. }
  355.  
  356. for (const button of document.getElementsByName('favorite-friend')) {
  357. button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0;
  358. }
  359. }
  360.  
  361. /**
  362. * ユーザーページへブックマーク登録/解除ボタンを追加します。
  363. * @returns {Promise.<void>}
  364. */
  365. async function insertFriendFavoriteButtons()
  366. {
  367. const homeContent = document.getElementsByClassName('home-content')[0];
  368. const unfriendButton = homeContent.querySelector('[aria-label="Unfriend"]');
  369. if (!unfriendButton) {
  370. return;
  371. }
  372.  
  373. const id = getUserIdFromLocation();
  374. if (!id) {
  375. return;
  376. }
  377.  
  378. const buttons = document.getElementsByName('favorite-friend');
  379. const groupNameDisplayNamePairs = await getFriendFavoriteGroupNameDisplayNamePairs();
  380. const groupNames = Object.keys(groupNameDisplayNamePairs);
  381. const buttonsParent = buttons[0] && buttons[0].closest('[role="group"]');
  382. if (buttonsParent) {
  383. // 多重挿入の防止
  384. if (buttonsParent.dataset.id === id) {
  385. return;
  386. } else {
  387. buttonsParent.remove();
  388. }
  389. }
  390. unfriendButton.parentElement.parentElement.parentElement.parentElement.nextElementSibling.firstElementChild
  391. .insertAdjacentHTML('beforeend', `<div role="group" class="mx-2 btn-group-lg btn-group-vertical"
  392. style="margin-top: -60px;"
  393. data-id="${h(id)}">
  394. ${groupNames.sort().map(tag => h`<button type="button"
  395. class="btn btn-secondary" name="favorite-friend" value="${tag}" disabled="">
  396. <span aria-hidden="true" class="fa fa-star"></span>
  397. &#xA0;<span class="name">${groupNameDisplayNamePairs[tag]}</span>
  398. &#xA0;<span class="count">‒</span>⁄${MAX_FRIEND_FAVORITE_COUNT_PER_GROUP}
  399. </button>`).join('')}
  400. </div>`);
  401.  
  402. await updateFriendFavoriteCounts();
  403.  
  404. const tags = [].concat(
  405. ...(await getFriendFavorites()).filter(favorite => favorite.favoriteId === id).map(favorite => favorite.tags),
  406. );
  407.  
  408. for (const button of buttons) {
  409. button.dataset.id = id;
  410. if (tags.includes(button.value)) {
  411. button.classList.remove('btn-secondary');
  412. button.classList.add('btn-primary');
  413. }
  414. if (button.classList.contains('btn-primary')
  415. || button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
  416. button.disabled = false;
  417. }
  418. }
  419.  
  420. buttons[0].closest('[role="group"]').addEventListener('click', async function (event) {
  421. const button = event.target.closest('button');
  422. if (!button || button.name !== 'favorite-friend') {
  423. return;
  424. }
  425.  
  426. const buttons = document.getElementsByName('favorite-friend');
  427. for (const button of buttons) {
  428. button.disabled = true;
  429. }
  430.  
  431. const id = button.dataset.id;
  432. const newTags = button.classList.contains('btn-secondary') ? [button.value] : [];
  433.  
  434. const favorites = await getFriendFavorites();
  435. for (let i = favorites.length - 1; i >= 0; i--) {
  436. if (favorites[i].favoriteId === id) {
  437. await fetch(
  438. '/api/1/favorites/' + favorites[i].id,
  439. {method: 'DELETE', credentials: 'same-origin'},
  440. );
  441.  
  442. for (const button of buttons) {
  443. if (favorites[i].tags.includes(button.value)) {
  444. button.classList.remove('btn-primary');
  445. button.classList.add('btn-secondary');
  446. }
  447. }
  448.  
  449. favorites.splice(i, 1);
  450. }
  451. }
  452.  
  453. if (newTags.length > 0) {
  454. await fetch('/api/1/favorites', {
  455. method: 'POST',
  456. headers: { 'content-type': 'application/json' },
  457. credentials: 'same-origin',
  458. body: JSON.stringify({type: 'friend', favoriteId: id, tags: newTags}),
  459. })
  460. .then(async response => response.ok ? response.json() : Promise.reject(
  461. new Error(`${response.status} ${response.statusText}\n${await response.text()}`),
  462. ))
  463. .then(function (favorite) {
  464. favorites.push(favorite);
  465. for (const button of buttons) {
  466. if (favorite.tags.includes(button.value)) {
  467. button.classList.remove('btn-secondary');
  468. button.classList.add('btn-primary');
  469. }
  470. }
  471. })
  472. .catch(showError);
  473. }
  474.  
  475. await updateFriendFavoriteCounts();
  476.  
  477. for (const button of buttons) {
  478. if (button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
  479. button.disabled = false;
  480. }
  481. }
  482. });
  483. }
  484.  
  485. /**
  486. * ログイン中のユーザーのグループ一覧。
  487. * @type {(string|boolean|number)[]?}
  488. */
  489. let authUserGroups;
  490.  
  491. /**
  492. * 指定したユーザーが参加しているグループを取得します。
  493. * @param {*} userId
  494. * @returns {Promise.<(string|boolean|number)[]>}
  495. */
  496. function fetchUserGroups(userId)
  497. {
  498. return fetchJSON(`https://vrchat.com/api/1/users/${userId}/groups`);
  499. }
  500.  
  501. /**
  502. * {@link location} からユーザーIDを抽出します。
  503. * @see {@link https://github.com/vrcx-team/VRCX/issues/429#issuecomment-1302920703}
  504. * @returns {string?}
  505. */
  506. function getUserIdFromLocation()
  507. {
  508. return /\/home\/user\/(usr_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-Za-z]{10})/
  509. .exec(location.pathname)?.[1];
  510. }
  511.  
  512. /**
  513. * ユーザーページへブグループへのinviteボタンを追加します。
  514. * @returns {void}
  515. */
  516. function insertInvitingToGroupButton()
  517. {
  518. const userId = getUserIdFromLocation();
  519. if (!userId) {
  520. return;
  521. }
  522.  
  523. const groupsHeading = Array.from(document.querySelectorAll('.home-content h2'))
  524. .find(heading => heading.lastChild?.data === '\'s Groups');
  525. if (!groupsHeading) {
  526. return;
  527. }
  528.  
  529. if (document.getElementsByName('open-inviting-to-group')[0]) {
  530. return;
  531. }
  532.  
  533. const displayName = document.querySelector('.home-content h2').textContent;
  534.  
  535. /*eslint-disable max-len */
  536. groupsHeading.insertAdjacentHTML('beforeend', h`
  537. <button type="button" name="open-inviting-to-group" class="btn btn-primary">
  538. <svg aria-hidden="true" class="svg-inline--fa fa-envelope" role="presentation"
  539. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  540. <path fill="currentColor" d="M464 64C490.5 64 512 85.49 512 112C512 127.1 504.9 141.3 492.8 150.4L275.2 313.6C263.8 322.1 248.2 322.1 236.8 313.6L19.2 150.4C7.113 141.3 0 127.1 0 112C0 85.49 21.49 64 48 64H464zM217.6 339.2C240.4 356.3 271.6 356.3 294.4 339.2L512 176V384C512 419.3 483.3 448 448 448H64C28.65 448 0 419.3 0 384V176L217.6 339.2z"></path>
  541. </svg>
  542. Invite to Group
  543. </button>
  544. <div id="user-page-inviting-to-group-dialog" tabindex="-1" hidden=""
  545. style="font-size: 1rem; line-height: initial; position: relative; z-index: 1050; display: block;"><div>
  546. <div class="modal fade show" style="display: block;" role="dialog" tabindex="-1">
  547. <div class="modal-dialog" role="document"><div class="modal-content">
  548. <div class="modal-header">
  549. <h5 class="modal-title"><h4 class="m-0">Invite to Group</h4></h5>
  550. <div><button name="close-inviting-to-group-dialog" aria-label="Close Button"
  551. style="padding: 5px; border-radius: 4px; border: 2px solid #333333; background: #333333;
  552. color: white;">
  553. <svg role="presentation" aria-hidden="true" class="svg-inline--fa fa-xmark fa-fw"
  554. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" width="20">
  555. <path fill="currentColor" d="M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z"></path>
  556. </svg>
  557. </button></div>
  558. </div>
  559. <div class="modal-body"></div>
  560. </div></div>
  561. </div>
  562. <div class="modal-backdrop fade show"></div>
  563. </div></div>
  564. `);
  565. /*eslint-enable max-len */
  566.  
  567. const dialog = document.getElementById('user-page-inviting-to-group-dialog');
  568.  
  569. groupsHeading.addEventListener('click', async function (event) {
  570. const button = event.target.closest('button');
  571. if (!button) {
  572. return;
  573. }
  574.  
  575. switch (button.name) {
  576. case 'open-inviting-to-group': {
  577. dialog.hidden = false;
  578.  
  579. const modalBody = dialog.getElementsByClassName('modal-body')[0];
  580. if (modalBody.firstElementChild) {
  581. break;
  582. }
  583.  
  584. if (!authUserGroups) {
  585. authUserGroups = await fetchUserGroups((await getUserDetails()).id);
  586. }
  587. const groupIds
  588. = Array.from(groupsHeading.nextElementSibling.querySelectorAll('[aria-label="Group Card"]'))
  589. .map(groupCard => /grp_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
  590. .exec(groupCard.pathname)[0]);
  591.  
  592. if (!document.getElementById('invite-to-group-style')) {
  593. document.head.insertAdjacentHTML('beforeend', `<style id="invite-to-group-style">
  594. [name="invite-to-group"] {
  595. --icon-size: 30px;
  596. --padding: 5px;
  597. padding: var(--padding) calc(var(--icon-size) + 2 * var(--padding));
  598. font-size: 1.2em;
  599. border: 2px solid #064B5C;
  600. border-radius: 4px;
  601. position: relative;
  602. color: #6AE3F9;
  603. background: #064B5C;
  604. overflow: hidden;
  605. text-overflow: ellipsis;
  606. white-space: nowrap;
  607. width: 100%;
  608. }
  609.  
  610. [name="invite-to-group"]:hover {
  611. border-color: #086C84;
  612. }
  613.  
  614. [name="invite-to-group"]:disabled {
  615. border: 2px solid #333333;
  616. background: #333333;
  617. color: #999999;
  618. }
  619.  
  620. [name="invite-to-group"] img {
  621. width: var(--icon-size);
  622. height: var(--icon-size);
  623. border-radius: 100%;
  624. border: 1px solid #181B1F;
  625. background-color: #181B1F;
  626. position: absolute;
  627. left: var(--padding);
  628. }
  629.  
  630. [role="alert"] {
  631. display: flex;
  632. flex-direction: column;
  633. background-color: #541D22BF;
  634. margin-top: 10px;
  635. border-radius: 3px;
  636. padding: 10px;
  637. border-left: 3px solid red;
  638. }
  639.  
  640. [role="alert"] > div:first-of-type {
  641. display: flex;
  642. align-items: center;
  643. }
  644.  
  645. [role="alert"] > div:first-of-type > div:first-of-type {
  646. font-size: 1.2rem;
  647. font-weight: bold;
  648. }
  649. </style>`);
  650. }
  651. /*eslint-disable indent */
  652. modalBody.innerHTML = authUserGroups.map(group => h`<div
  653. class="mt-2 mb-2 d-flex flex-column justify-content-center">
  654. <div style="position: relative; border-radius: 4px;">
  655. <button name="invite-to-group" value="${h(group.groupId)}"` + (groupIds.includes(group.groupId)
  656. ? h` disabled="" title="${displayName} is already a member of this group․"`
  657. : '') + h`>
  658. <img src="${group.iconUrl}">
  659. ${group.name}
  660. </button>
  661. </div>
  662. </div>`).join('');
  663. /*eslint-enable indent */
  664. break;
  665. }
  666. case 'invite-to-group': {
  667. const enabledButtons = Array.from(dialog.querySelectorAll('button:enabled'));
  668. try {
  669. for (const button of enabledButtons) {
  670. button.disabled = true;
  671. }
  672.  
  673. const response = await fetch(`/api/1/groups/${button.value}/invites`, {
  674. method: 'POST',
  675. headers: { 'content-type': 'application/json' },
  676. credentials: 'same-origin',
  677. body: JSON.stringify({ userId, confirmOverrideBlock: true }),
  678. });
  679. if (!response.ok) {
  680. const { error: { message } } = await response.json();
  681. /*eslint-disable max-len */
  682. button.parentElement.insertAdjacentHTML('beforebegin', h`<div role="alert"
  683. aria-label="Couldn't invite user">
  684. <div>
  685. <svg aria-hidden="true" class="svg-inline--fa fa-circle-exclamation me-2"
  686. role="presentation"
  687. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" color="red">
  688. <path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM232 152C232 138.8 242.8 128 256 128s24 10.75 24 24v128c0 13.25-10.75 24-24 24S232 293.3 232 280V152zM256 400c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 385.9 273.4 400 256 400z"></path>
  689. </svg>
  690. <div>Couldn't invite user</div>
  691. </div>
  692. <div>${response.statusText}: ${message}</div>
  693. </div>`);
  694. /*eslint-enable max-len */
  695. }
  696. enabledButtons.splice(enabledButtons.indexOf(button), 1);
  697. } finally {
  698. for (const button of enabledButtons) {
  699. button.disabled = false;
  700. }
  701. }
  702. break;
  703. }
  704. case 'close-inviting-to-group-dialog':
  705. dialog.hidden = true;
  706. break;
  707. }
  708. });
  709. }
  710.  
  711. /**
  712. * Friend Locationsページのインスタンスへ、現在のインスタンス人数と上限を表示します。
  713. * @param {HTMLDivElement} location
  714. * @returns {void}
  715. */
  716. function insertInstanceUserCountAndCapacity(location, worldId, instanceId)
  717. {
  718. const world = worlds[worldId];
  719. const instanceUserCount = world?.instances?.find(([ id ]) => id === instanceId)?.[1];
  720.  
  721. /** @type {HTMLElement} */
  722. let counts = location.getElementsByClassName('instance-user-count-and-capacity')[0];
  723. if (!counts) {
  724. const button = location.querySelector('[aria-label="Invite Me"]');
  725. const friendCount = location.querySelector('[aria-label="Invite Me"]').parentElement.previousElementSibling;
  726. counts = friendCount.cloneNode();
  727. counts.classList.add('instance-user-count-and-capacity');
  728. const reloadButton = button.cloneNode();
  729. reloadButton.setAttribute('aria-label', 'Reload');
  730. reloadButton.textContent = '↺';
  731. reloadButton.addEventListener('click', async function (event) {
  732. const instance
  733. = await fetchJSON(`/api/1/instances/${worldId}:${instanceId}`, { credentials: 'same-origin' });
  734. event.target.previousSibling.data = instance.userCount + ' / ' + instance.capacity;
  735. });
  736. counts.append('', reloadButton);
  737. friendCount.before(counts);
  738. }
  739. counts.firstChild.data = (instanceUserCount ?? '?') + ' / ' + (world?.capacity ?? '?');
  740. }
  741.  
  742. /**
  743. * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。
  744. * @type {boolean}
  745. * @access private
  746. */
  747. let headChildrenInserted = false;
  748.  
  749. const homeContents = document.getElementsByClassName('home-content');
  750.  
  751. new MutationObserver(function (mutations, observer) {
  752. if (document.head && !headChildrenInserted) {
  753. headChildrenInserted = true;
  754. document.head.insertAdjacentHTML('beforeend', `<style>
  755. /*====================================
  756. Friend Locations
  757. */
  758. .instance-user-count-and-capacity {
  759. white-space: nowrap;
  760. }
  761.  
  762. .instance-user-count-and-capacity button {
  763. margin: 0 0 0 0.5em !important;
  764. padding: unset;
  765. line-height: 1;
  766. }
  767.  
  768. /*====================================
  769. Edit Profile
  770. */
  771. ` + Object.keys(STATUSES).map(status => `
  772. #select-status.${CSS.escape(status.replace(' ', '-'))},
  773. #select-status option[value=${CSS.escape(status)}] {
  774. color: var(${STATUSES[status].color});
  775. }
  776. `).join('') + `
  777.  
  778. /*====================================
  779. フレンドのユーザーページ
  780. */
  781. .btn[name^="favorite-"] {
  782. white-space: unset;
  783. }
  784. </style>`);
  785.  
  786. // ユーザー情報・ワールド情報・グループ情報を取得
  787. GreasemonkeyUtils.executeOnUnsafeContext(function (id) {
  788. Response.prototype.text = new Proxy(Response.prototype.text, {
  789. apply(get, thisArgument, argumentList)
  790. {
  791. const textPromise = Reflect.apply(get, thisArgument, argumentList);
  792. (async function () {
  793. const data = { id };
  794. const pathname = new URL(thisArgument.url).pathname;
  795. if (pathname === '/api/1/auth/user') {
  796. data.userDetails = JSON.parse(await textPromise);
  797. } else if (pathname.startsWith('/api/1/worlds/wrld_')) {
  798. data.world = JSON.parse(await textPromise);
  799. } else if (pathname.startsWith('/api/1/groups/grp_')) {
  800. data.group = JSON.parse(await textPromise);
  801. } else {
  802. return;
  803. }
  804. postMessage(data, location.origin);
  805. })();
  806. return textPromise;
  807. },
  808. });
  809. }, [ ID ]);
  810. }
  811.  
  812. if (!homeContents[0]) {
  813. return;
  814. }
  815.  
  816. const locationsList = homeContents[0].getElementsByClassName('locations');
  817. const instanceUserCountAndCapacityList = homeContents[0].getElementsByClassName('instance-user-count-and-capacity');
  818.  
  819. new MutationObserver(async function (mutations) {
  820. for (const mutation of mutations) {
  821. if (locationsList[0]) {
  822. if (locationsList[0].children.length !== instanceUserCountAndCapacityList.length) {
  823. // Friend Locationsへインスタンス人数を追加
  824. for (const location of locationsList[0].children) {
  825. if (location.getElementsByClassName('instance-user-count-and-capacity')[0]) {
  826. continue;
  827. }
  828.  
  829. const launchLink = location.querySelector('[href*="/home/launch?"]');
  830. if (!launchLink) {
  831. continue;
  832. }
  833. const params = new URLSearchParams(launchLink.search);
  834. insertInstanceUserCountAndCapacity(location, params.get('worldId'), params.get('instanceId'));
  835. }
  836. }
  837. } else if (mutation.addedNodes.length > 0 && mutation.target.nodeType === Node.ELEMENT_NODE
  838. && (/* ユーザーページを開いたとき */ mutation.target.classList.contains('home-content')
  839. || mutation.target.localName === 'div'
  840. && mutation.addedNodes.length === 1 && mutation.addedNodes[0].localName === 'div'
  841. && mutation.addedNodes[0]
  842. .querySelector('[aria-label="Add Friend"], [aria-label="Unfriend"]')
  843. || /* ワールドページを開いたとき */ mutation.target.parentElement.classList.contains('home-content')
  844. || /* グループページでタブ移動したとき */ mutation.target.parentElement.parentElement
  845. .classList.contains('home-content'))
  846. || /* ユーザーページ間を移動したとき */ mutation.type === 'characterData'
  847. && mutation.target.nextSibling?.data === '\'s Profile') {
  848. if (location.pathname.startsWith('/home/user/')) {
  849. // ユーザーページ
  850. await insertSelectStatusAndStatusMessageHistory();
  851. insertInvitingToGroupButton();
  852. await insertFriendFavoriteButtons('friend');
  853. } else if (location.pathname.startsWith('/home/world/')) {
  854. // ワールドページ
  855. const heading = document.querySelector('.home-content h2');
  856. const name = heading.firstChild.data;
  857. const author
  858. = heading.nextElementSibling.querySelector('[href^="/home/user/"]').firstChild.data;
  859. document.title = `${name} By ${author} - VRChat`;
  860. } else if (location.pathname.startsWith('/home/avatar/')) {
  861. // アバターページ
  862. const name = document.querySelector('.home-content h3').textContent;
  863. const author = document.querySelector('.home-content [href^="/home/user/"]').text;
  864. document.title = `${name} By ${author} - VRChat`;
  865. } else if (location.pathname.startsWith('/home/group/')) {
  866. // グループページ
  867. const name = document.querySelector('.home-content h2').textContent;
  868. const groupLink = document.querySelector('[href^="https://vrc.group/"]');
  869. const shortCodeAndDiscriminator = groupLink.textContent;
  870. document.title = `${name} ${shortCodeAndDiscriminator} - VRChat`;
  871.  
  872. // グループオーナーへのリンクを追加
  873. setTimeout(function () {
  874. if (!document.getElementById('group-owner-link')) {
  875. const groupLinkColumn = groupLink.closest('div');
  876. groupLinkColumn.style.marginLeft = '1em';
  877. const column = groupLinkColumn.cloneNode();
  878. const ownerId = groups[/^\/home\/group\/([^/]+)/.exec(location.pathname)[1]].ownerId;
  879. column.innerHTML = h`<a id="group-owner-link" href="/home/user/${ownerId}">
  880. Group Owner
  881. </a>`;
  882. groupLinkColumn.after(column);
  883. }
  884. });
  885. }
  886. break;
  887. }
  888. }
  889. }).observe(homeContents[0], {childList: true, characterData: true, subtree: true });
  890.  
  891. observer.disconnect();
  892. }).observe(document, {childList: true, subtree: true});