VRChat Web Pages Extender

Add features into VRChat Web Pages and improve user experience.

目前为 2024-06-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.19.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. * 一つのブックマークグループの最大登録数。
  85. * @constant {Object.<number>}
  86. */
  87. const MAX_FRIEND_FAVORITE_COUNT_PER_GROUP = 150;
  88.  
  89. /**
  90. * @type {Function}
  91. * @access private
  92. */
  93. let setUserDetails;
  94.  
  95. /**
  96. * @type {Promise.<Object>}
  97. * @access private
  98. */
  99. let userDetails = new Promise(function (resolve) {
  100. let settled = false;
  101. setUserDetails = function (details) {
  102. if (settled) {
  103. userDetails = Promise.resolve(details);
  104. } else {
  105. settled = true;
  106. resolve(details);
  107. }
  108. };
  109. });
  110.  
  111. /**
  112. * キーにワールドIDを持つ連想配列。
  113. * @type {Object.<(string|string[]|boolean|number|Object.<(string|number)>[]|(string|number)[][]|null)>}
  114. */
  115. const worlds = { };
  116.  
  117. /**
  118. * キーにグループIDを持つ連想配列。
  119. * @type {Object.<string,(string|string[]|number|boolean|Object.<string,(string|string[]|boolean)?>)?>[]}
  120. */
  121. const groups = { };
  122.  
  123. addEventListener('message', function (event) {
  124. if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null
  125. || event.data.id !== ID) {
  126. return;
  127. }
  128.  
  129. if (event.data.userDetails) {
  130. setUserDetails(event.data.userDetails);
  131. } else if (event.data.world) {
  132. worlds[event.data.world.id] = event.data.world;
  133. const locations = document.getElementsByClassName('locations')[0];
  134. if (!locations) {
  135. return;
  136. }
  137. for (const [ instanceId ] of event.data.world.instances) {
  138. const locationLink = locations.querySelector(`.locations
  139. [href*=${CSS.escape(`/home/launch?worldId=${event.data.world.id}&instanceId=${instanceId}`)}]`);
  140. if (!locationLink) {
  141. continue;
  142. }
  143. insertInstanceUserCountAndCapacity(locationLink.closest('.locations > *'), event.data.world.id, instanceId);
  144. }
  145. } else if (event.data.group) {
  146. groups[event.data.group.id] = event.data.group;
  147. }
  148. });
  149.  
  150. /**
  151. * ログインしているユーザーの情報を取得します。
  152. * @see [User Info — VRChat API Documentation]{@link https://vrchatapi.github.io/#/UserAPI/CurrentUserDetails}
  153. * @returns {Promise.<?Object.<(string|string[]|boolean|number|Object)>>}
  154. */
  155. async function getUserDetails()
  156. {
  157. return await userDetails;
  158. }
  159.  
  160. /**
  161. * JSONファイルをオブジェクトとして取得します。
  162. * @param {string} url
  163. * @returns {Promise.<(Object|Array)>} OKステータスでなければ失敗します。
  164. */
  165. async function fetchJSON(url)
  166. {
  167. const response = await fetch(url, {credentials: 'same-origin'});
  168. return response.ok
  169. ? response.json()
  170. : Promise.reject(new Error(`${response.status} ${response.statusText}\n${await response.text()}`));
  171. }
  172.  
  173. let friendFavoriteGroupNameDisplayNamePairs;
  174.  
  175. /**
  176. * ログインしているユーザーのフレンドfavoriteのグループ名を取得します。
  177. * @returns {Promise.<Object.<string>[]>}
  178. */
  179. function getFriendFavoriteGroupNameDisplayNamePairs()
  180. {
  181. if (!friendFavoriteGroupNameDisplayNamePairs) {
  182. friendFavoriteGroupNameDisplayNamePairs
  183. = fetchJSON('/api/1/favorite/groups?type=friend', {credentials: 'same-origin'}).then(function (groups) {
  184. const groupNameDisplayNamePairs = {};
  185. for (const group of groups) {
  186. groupNameDisplayNamePairs[group.name] = group.displayName;
  187. }
  188. return groupNameDisplayNamePairs;
  189. });
  190. }
  191. return friendFavoriteGroupNameDisplayNamePairs;
  192. }
  193.  
  194. /**
  195. * @type {Promise.<Object.<(string|string[])>[]>}
  196. * @access private
  197. */
  198. let friendFavoritesPromise;
  199.  
  200. /**
  201. * ブックマークを全件取得します。
  202. * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites}
  203. * @returns {Promise.<Object.<(string|string[])>[]>}
  204. */
  205. function getFriendFavorites()
  206. {
  207. return friendFavoritesPromise || (friendFavoritesPromise = async function () {
  208. const allFavorites = [];
  209. let offset = 0;
  210.  
  211. while (true) { //eslint-disable-line no-constant-condition
  212. const favorites = await fetchJSON(
  213. `/api/1/favorites/?type=friend&n=${MAX_ITEMS_COUNT}&offset=${offset}`,
  214. ).catch(showError);
  215.  
  216. allFavorites.push(...favorites);
  217.  
  218. if (favorites.length < MAX_ITEMS_COUNT) {
  219. break;
  220. }
  221.  
  222. offset += favorites.length;
  223. }
  224. return allFavorites;
  225. }());
  226. }
  227.  
  228. /**
  229. * 自分のユーザーページの編集ダイアログのステータスメッセージ入力欄へ履歴を追加します。
  230. * @returns {Promise.<void>}
  231. */
  232. async function insertStatusMessageHistory()
  233. {
  234. if (document.getElementById('input-status-message-history')) {
  235. // すでに挿入済みなら
  236. return;
  237. }
  238.  
  239. const inputStatusMessage = document.getElementById('input-status-message');
  240. if (!inputStatusMessage) {
  241. return;
  242. }
  243.  
  244. // ステータスメッセージ入力欄へ履歴を追加
  245. inputStatusMessage.insertAdjacentHTML('afterend', `<datalist id="input-status-message-history">
  246. ${(await getUserDetails())
  247. .statusHistory.map(statusDescription => h`<option>${statusDescription}</option>`).join('')}
  248. </datalist>`);
  249. inputStatusMessage.setAttribute('list', inputStatusMessage.nextElementSibling.id);
  250. }
  251.  
  252. /**
  253. * フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。
  254. * @returns {Promise.<void>}
  255. */
  256. async function updateFriendFavoriteCounts()
  257. {
  258. const counts = {};
  259. for (const favorite of await getFriendFavorites()) {
  260. for (const tag of favorite.tags) {
  261. if (!(tag in counts)) {
  262. counts[tag] = 0;
  263. }
  264. counts[tag]++;
  265. }
  266. }
  267.  
  268. for (const button of document.getElementsByName('favorite-friend')) {
  269. button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0;
  270. }
  271. }
  272.  
  273. /**
  274. * ユーザーページへブックマーク登録/解除ボタンを追加します。
  275. * @returns {Promise.<void>}
  276. */
  277. async function insertFriendFavoriteButtons()
  278. {
  279. const homeContent = document.getElementsByClassName('home-content')[0];
  280. const unfriendButton = homeContent.querySelector('[aria-label="Unfriend"]');
  281. if (!unfriendButton) {
  282. return;
  283. }
  284.  
  285. const id = getUserIdFromLocation();
  286. if (!id) {
  287. return;
  288. }
  289.  
  290. const buttons = document.getElementsByName('favorite-friend');
  291. const groupNameDisplayNamePairs = await getFriendFavoriteGroupNameDisplayNamePairs();
  292. const groupNames = Object.keys(groupNameDisplayNamePairs);
  293. const buttonsParent = buttons[0] && buttons[0].closest('[role="group"]');
  294. if (buttonsParent) {
  295. // 多重挿入の防止
  296. if (buttonsParent.dataset.id === id) {
  297. return;
  298. } else {
  299. buttonsParent.remove();
  300. }
  301. }
  302. unfriendButton.parentElement.parentElement.parentElement.parentElement.nextElementSibling.firstElementChild
  303. .insertAdjacentHTML('beforeend', `<div role="group" class="mx-2 btn-group-lg btn-group-vertical"
  304. style="margin-top: -60px;"
  305. data-id="${h(id)}">
  306. ${groupNames.sort().map(tag => h`<button type="button"
  307. class="btn btn-secondary" name="favorite-friend" value="${tag}" disabled="">
  308. <span aria-hidden="true" class="fa fa-star"></span>
  309. &#xA0;<span class="name">${groupNameDisplayNamePairs[tag]}</span>
  310. &#xA0;<span class="count">‒</span>⁄${MAX_FRIEND_FAVORITE_COUNT_PER_GROUP}
  311. </button>`).join('')}
  312. </div>`);
  313.  
  314. await updateFriendFavoriteCounts();
  315.  
  316. const tags = [].concat(
  317. ...(await getFriendFavorites()).filter(favorite => favorite.favoriteId === id).map(favorite => favorite.tags),
  318. );
  319.  
  320. for (const button of buttons) {
  321. button.dataset.id = id;
  322. if (tags.includes(button.value)) {
  323. button.classList.remove('btn-secondary');
  324. button.classList.add('btn-primary');
  325. }
  326. if (button.classList.contains('btn-primary')
  327. || button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
  328. button.disabled = false;
  329. }
  330. }
  331.  
  332. buttons[0].closest('[role="group"]').addEventListener('click', async function (event) {
  333. const button = event.target.closest('button');
  334. if (!button || button.name !== 'favorite-friend') {
  335. return;
  336. }
  337.  
  338. const buttons = document.getElementsByName('favorite-friend');
  339. for (const button of buttons) {
  340. button.disabled = true;
  341. }
  342.  
  343. const id = button.dataset.id;
  344. const newTags = button.classList.contains('btn-secondary') ? [button.value] : [];
  345.  
  346. const favorites = await getFriendFavorites();
  347. for (let i = favorites.length - 1; i >= 0; i--) {
  348. if (favorites[i].favoriteId === id) {
  349. await fetch(
  350. '/api/1/favorites/' + favorites[i].id,
  351. {method: 'DELETE', credentials: 'same-origin'},
  352. );
  353.  
  354. for (const button of buttons) {
  355. if (favorites[i].tags.includes(button.value)) {
  356. button.classList.remove('btn-primary');
  357. button.classList.add('btn-secondary');
  358. }
  359. }
  360.  
  361. favorites.splice(i, 1);
  362. }
  363. }
  364.  
  365. if (newTags.length > 0) {
  366. await fetch('/api/1/favorites', {
  367. method: 'POST',
  368. headers: { 'content-type': 'application/json' },
  369. credentials: 'same-origin',
  370. body: JSON.stringify({type: 'friend', favoriteId: id, tags: newTags}),
  371. })
  372. .then(async response => response.ok ? response.json() : Promise.reject(
  373. new Error(`${response.status} ${response.statusText}\n${await response.text()}`),
  374. ))
  375. .then(function (favorite) {
  376. favorites.push(favorite);
  377. for (const button of buttons) {
  378. if (favorite.tags.includes(button.value)) {
  379. button.classList.remove('btn-secondary');
  380. button.classList.add('btn-primary');
  381. }
  382. }
  383. })
  384. .catch(showError);
  385. }
  386.  
  387. await updateFriendFavoriteCounts();
  388.  
  389. for (const button of buttons) {
  390. if (button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
  391. button.disabled = false;
  392. }
  393. }
  394. });
  395. }
  396.  
  397. /**
  398. * ログイン中のユーザーのグループ一覧。
  399. * @type {(string|boolean|number)[]?}
  400. */
  401. let authUserGroups;
  402.  
  403. /**
  404. * 指定したユーザーが参加しているグループを取得します。
  405. * @param {*} userId
  406. * @returns {Promise.<(string|boolean|number)[]>}
  407. */
  408. function fetchUserGroups(userId)
  409. {
  410. return fetchJSON(`https://vrchat.com/api/1/users/${userId}/groups`);
  411. }
  412.  
  413. /**
  414. * {@link location} からユーザーIDを抽出します。
  415. * @see {@link https://github.com/vrcx-team/VRCX/issues/429#issuecomment-1302920703}
  416. * @returns {string?}
  417. */
  418. function getUserIdFromLocation()
  419. {
  420. 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})/
  421. .exec(location.pathname)?.[1];
  422. }
  423.  
  424. /**
  425. * ユーザーページへブグループへのinviteボタンを追加します。
  426. * @returns {void}
  427. */
  428. function insertInvitingToGroupButton()
  429. {
  430. const userId = getUserIdFromLocation();
  431. if (!userId) {
  432. return;
  433. }
  434.  
  435. const groupsHeading = Array.from(document.querySelectorAll('.home-content h2'))
  436. .find(heading => heading.lastChild?.data === '\'s Groups');
  437. if (!groupsHeading) {
  438. return;
  439. }
  440.  
  441. if (document.getElementsByName('open-inviting-to-group')[0]) {
  442. return;
  443. }
  444.  
  445. const displayName = document.querySelector('.home-content h2').textContent;
  446.  
  447. /*eslint-disable max-len */
  448. groupsHeading.insertAdjacentHTML('beforeend', h`
  449. <button type="button" name="open-inviting-to-group" class="btn btn-primary">
  450. <svg aria-hidden="true" class="svg-inline--fa fa-envelope" role="presentation"
  451. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  452. <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>
  453. </svg>
  454. Invite to Group
  455. </button>
  456. <div id="user-page-inviting-to-group-dialog" tabindex="-1" hidden=""
  457. style="font-size: 1rem; line-height: initial; position: relative; z-index: 1050; display: block;"><div>
  458. <div class="modal fade show" style="display: block;" role="dialog" tabindex="-1">
  459. <div class="modal-dialog" role="document"><div class="modal-content">
  460. <div class="modal-header">
  461. <h5 class="modal-title"><h4 class="m-0">Invite to Group</h4></h5>
  462. <div><button name="close-inviting-to-group-dialog" aria-label="Close Button"
  463. style="padding: 5px; border-radius: 4px; border: 2px solid #333333; background: #333333;
  464. color: white;">
  465. <svg role="presentation" aria-hidden="true" class="svg-inline--fa fa-xmark fa-fw"
  466. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" width="20">
  467. <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>
  468. </svg>
  469. </button></div>
  470. </div>
  471. <div class="modal-body"></div>
  472. </div></div>
  473. </div>
  474. <div class="modal-backdrop fade show"></div>
  475. </div></div>
  476. `);
  477. /*eslint-enable max-len */
  478.  
  479. const dialog = document.getElementById('user-page-inviting-to-group-dialog');
  480.  
  481. groupsHeading.addEventListener('click', async function (event) {
  482. const button = event.target.closest('button');
  483. if (!button) {
  484. return;
  485. }
  486.  
  487. switch (button.name) {
  488. case 'open-inviting-to-group': {
  489. dialog.hidden = false;
  490.  
  491. const modalBody = dialog.getElementsByClassName('modal-body')[0];
  492. if (modalBody.firstElementChild) {
  493. break;
  494. }
  495.  
  496. if (!authUserGroups) {
  497. authUserGroups = await fetchUserGroups((await getUserDetails()).id);
  498. }
  499. const groupIds
  500. = Array.from(groupsHeading.nextElementSibling.querySelectorAll('[aria-label="Group Card"]'))
  501. .map(groupCard => /grp_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
  502. .exec(groupCard.pathname)[0]);
  503.  
  504. if (!document.getElementById('invite-to-group-style')) {
  505. document.head.insertAdjacentHTML('beforeend', `<style id="invite-to-group-style">
  506. [name="invite-to-group"] {
  507. --icon-size: 30px;
  508. --padding: 5px;
  509. padding: var(--padding) calc(var(--icon-size) + 2 * var(--padding));
  510. font-size: 1.2em;
  511. border: 2px solid #064B5C;
  512. border-radius: 4px;
  513. position: relative;
  514. color: #6AE3F9;
  515. background: #064B5C;
  516. overflow: hidden;
  517. text-overflow: ellipsis;
  518. white-space: nowrap;
  519. width: 100%;
  520. }
  521.  
  522. [name="invite-to-group"]:hover {
  523. border-color: #086C84;
  524. }
  525.  
  526. [name="invite-to-group"]:disabled {
  527. border: 2px solid #333333;
  528. background: #333333;
  529. color: #999999;
  530. }
  531.  
  532. [name="invite-to-group"] img {
  533. width: var(--icon-size);
  534. height: var(--icon-size);
  535. border-radius: 100%;
  536. border: 1px solid #181B1F;
  537. background-color: #181B1F;
  538. position: absolute;
  539. left: var(--padding);
  540. }
  541.  
  542. [role="alert"] {
  543. display: flex;
  544. flex-direction: column;
  545. background-color: #541D22BF;
  546. margin-top: 10px;
  547. border-radius: 3px;
  548. padding: 10px;
  549. border-left: 3px solid red;
  550. }
  551.  
  552. [role="alert"] > div:first-of-type {
  553. display: flex;
  554. align-items: center;
  555. }
  556.  
  557. [role="alert"] > div:first-of-type > div:first-of-type {
  558. font-size: 1.2rem;
  559. font-weight: bold;
  560. }
  561. </style>`);
  562. }
  563. /*eslint-disable indent */
  564. modalBody.innerHTML = authUserGroups.map(group => h`<div
  565. class="mt-2 mb-2 d-flex flex-column justify-content-center">
  566. <div style="position: relative; border-radius: 4px;">
  567. <button name="invite-to-group" value="${h(group.groupId)}"` + (groupIds.includes(group.groupId)
  568. ? h` disabled="" title="${displayName} is already a member of this group․"`
  569. : '') + h`>
  570. <img src="${group.iconUrl}">
  571. ${group.name}
  572. </button>
  573. </div>
  574. </div>`).join('');
  575. /*eslint-enable indent */
  576. break;
  577. }
  578. case 'invite-to-group': {
  579. const enabledButtons = Array.from(dialog.querySelectorAll('button:enabled'));
  580. try {
  581. for (const button of enabledButtons) {
  582. button.disabled = true;
  583. }
  584.  
  585. const response = await fetch(`/api/1/groups/${button.value}/invites`, {
  586. method: 'POST',
  587. headers: { 'content-type': 'application/json' },
  588. credentials: 'same-origin',
  589. body: JSON.stringify({ userId, confirmOverrideBlock: true }),
  590. });
  591. if (!response.ok) {
  592. const { error: { message } } = await response.json();
  593. /*eslint-disable max-len */
  594. button.parentElement.insertAdjacentHTML('beforebegin', h`<div role="alert"
  595. aria-label="Couldn't invite user">
  596. <div>
  597. <svg aria-hidden="true" class="svg-inline--fa fa-circle-exclamation me-2"
  598. role="presentation"
  599. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" color="red">
  600. <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>
  601. </svg>
  602. <div>Couldn't invite user</div>
  603. </div>
  604. <div>${response.statusText}: ${message}</div>
  605. </div>`);
  606. /*eslint-enable max-len */
  607. }
  608. enabledButtons.splice(enabledButtons.indexOf(button), 1);
  609. } finally {
  610. for (const button of enabledButtons) {
  611. button.disabled = false;
  612. }
  613. }
  614. break;
  615. }
  616. case 'close-inviting-to-group-dialog':
  617. dialog.hidden = true;
  618. break;
  619. }
  620. });
  621. }
  622.  
  623. /**
  624. * Friend Locationsページのインスタンスへ、現在のインスタンス人数と上限を表示します。
  625. * @param {HTMLDivElement} location
  626. * @returns {void}
  627. */
  628. function insertInstanceUserCountAndCapacity(location, worldId, instanceId)
  629. {
  630. const world = worlds[worldId];
  631. const instanceUserCount = world?.instances?.find(([ id ]) => id === instanceId)?.[1];
  632.  
  633. /** @type {HTMLElement} */
  634. let counts = location.getElementsByClassName('instance-user-count-and-capacity')[0];
  635. if (!counts) {
  636. const button = location.querySelector('[aria-label="Invite Me"]');
  637. const friendCount = location.querySelector('[aria-label="Invite Me"]').parentElement.previousElementSibling;
  638. counts = friendCount.cloneNode();
  639. counts.classList.add('instance-user-count-and-capacity');
  640. const reloadButton = button.cloneNode();
  641. reloadButton.setAttribute('aria-label', 'Reload');
  642. reloadButton.textContent = '↺';
  643. reloadButton.addEventListener('click', async function (event) {
  644. const instance
  645. = await fetchJSON(`/api/1/instances/${worldId}:${instanceId}`, { credentials: 'same-origin' });
  646. event.target.previousSibling.data = instance.userCount + ' / ' + instance.capacity;
  647. });
  648. counts.append('', reloadButton);
  649. friendCount.before(counts);
  650. }
  651. counts.firstChild.data = (instanceUserCount ?? '?') + ' / ' + (world?.capacity ?? '?');
  652. }
  653.  
  654. /**
  655. * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。
  656. * @type {boolean}
  657. * @access private
  658. */
  659. let headChildrenInserted = false;
  660.  
  661. const homeContents = document.getElementsByClassName('home-content');
  662.  
  663. new MutationObserver(function (mutations, observer) {
  664. if (document.head && !headChildrenInserted) {
  665. headChildrenInserted = true;
  666. document.head.insertAdjacentHTML('beforeend', `<style>
  667. /*====================================
  668. Friend Locations
  669. */
  670. .instance-user-count-and-capacity {
  671. white-space: nowrap;
  672. }
  673.  
  674. .instance-user-count-and-capacity button {
  675. margin: 0 0 0 0.5em !important;
  676. padding: unset;
  677. line-height: 1;
  678. }
  679.  
  680. /*====================================
  681. フレンドのユーザーページ
  682. */
  683. .btn[name^="favorite-"] {
  684. white-space: unset;
  685. }
  686. </style>`);
  687.  
  688. // ユーザー情報・ワールド情報・グループ情報を取得
  689. GreasemonkeyUtils.executeOnUnsafeContext(function (id) {
  690. Response.prototype.text = new Proxy(Response.prototype.text, {
  691. apply(get, thisArgument, argumentList)
  692. {
  693. const textPromise = Reflect.apply(get, thisArgument, argumentList);
  694. (async function () {
  695. const data = { id };
  696. const pathname = new URL(thisArgument.url).pathname;
  697. if (pathname === '/api/1/auth/user') {
  698. data.userDetails = JSON.parse(await textPromise);
  699. } else if (pathname.startsWith('/api/1/worlds/wrld_')) {
  700. data.world = JSON.parse(await textPromise);
  701. } else if (pathname.startsWith('/api/1/groups/grp_')) {
  702. data.group = JSON.parse(await textPromise);
  703. } else {
  704. return;
  705. }
  706. postMessage(data, location.origin);
  707. })();
  708. return textPromise;
  709. },
  710. });
  711. }, [ ID ]);
  712. }
  713.  
  714. if (!homeContents[0]) {
  715. return;
  716. }
  717.  
  718. const locationsList = homeContents[0].getElementsByClassName('locations');
  719. const instanceUserCountAndCapacityList = homeContents[0].getElementsByClassName('instance-user-count-and-capacity');
  720.  
  721. new MutationObserver(async function (mutations) {
  722. for (const mutation of mutations) {
  723. if (locationsList[0]) {
  724. if (locationsList[0].children.length !== instanceUserCountAndCapacityList.length) {
  725. // Friend Locationsへインスタンス人数を追加
  726. for (const location of locationsList[0].children) {
  727. if (location.getElementsByClassName('instance-user-count-and-capacity')[0]) {
  728. continue;
  729. }
  730.  
  731. const launchLink = location.querySelector('[href*="/home/launch?"]');
  732. if (!launchLink) {
  733. continue;
  734. }
  735. const params = new URLSearchParams(launchLink.search);
  736. insertInstanceUserCountAndCapacity(location, params.get('worldId'), params.get('instanceId'));
  737. }
  738. }
  739. } else if (mutation.addedNodes.length > 0 && mutation.target.nodeType === Node.ELEMENT_NODE
  740. && (/* ユーザーページを開いたとき */ mutation.target.classList.contains('home-content')
  741. || mutation.target.localName === 'div'
  742. && mutation.addedNodes.length === 1 && mutation.addedNodes[0].localName === 'div'
  743. && mutation.addedNodes[0]
  744. .querySelector('[aria-label="Add Friend"], [aria-label="Unfriend"]')
  745. || /* ワールドページを開いたとき */ mutation.target.parentElement.classList.contains('home-content')
  746. || /* グループページでタブ移動したとき */ mutation.target.parentElement.parentElement
  747. .classList.contains('home-content'))
  748. || /* ユーザーページ間を移動したとき */ mutation.type === 'characterData'
  749. && mutation.target.nextSibling?.data === '\'s Profile') {
  750. if (location.pathname.startsWith('/home/user/')) {
  751. // ユーザーページ
  752. await insertStatusMessageHistory();
  753. insertInvitingToGroupButton();
  754. await insertFriendFavoriteButtons('friend');
  755. } else if (location.pathname.startsWith('/home/world/')) {
  756. // ワールドページ
  757. const heading = document.querySelector('.home-content h2');
  758. const name = heading.firstChild.data;
  759. const author
  760. = heading.nextElementSibling.querySelector('[href^="/home/user/"]').firstChild.data;
  761. document.title = `${name} By ${author} - VRChat`;
  762. } else if (location.pathname.startsWith('/home/avatar/')) {
  763. // アバターページ
  764. const name = document.querySelector('.home-content h3').textContent;
  765. const author = document.querySelector('.home-content [href^="/home/user/"]').text;
  766. document.title = `${name} By ${author} - VRChat`;
  767. } else if (location.pathname.startsWith('/home/group/')) {
  768. // グループページ
  769. const name = document.querySelector('.home-content h2').textContent;
  770. const groupLink = document.querySelector('[href^="https://vrc.group/"]');
  771. const shortCodeAndDiscriminator = groupLink.textContent;
  772. document.title = `${name} ${shortCodeAndDiscriminator} - VRChat`;
  773.  
  774. // グループオーナーへのリンクを追加
  775. setTimeout(function () {
  776. if (!document.getElementById('group-owner-link')) {
  777. const groupLinkColumn = groupLink.closest('div');
  778. groupLinkColumn.style.marginLeft = '1em';
  779. const column = groupLinkColumn.cloneNode();
  780. const ownerId = groups[/^\/home\/group\/([^/]+)/.exec(location.pathname)[1]].ownerId;
  781. column.innerHTML = h`<a id="group-owner-link" href="/home/user/${ownerId}">
  782. Group Owner
  783. </a>`;
  784. groupLinkColumn.after(column);
  785. }
  786. });
  787. }
  788. break;
  789. }
  790. }
  791. }).observe(homeContents[0], {childList: true, characterData: true, subtree: true });
  792.  
  793. observer.disconnect();
  794. }).observe(document, {childList: true, subtree: true});