VRChat Web Pages Extender

Add features into VRChat Web Pages and improve user experience.

  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.22.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. * 絵文字、ステッカーをアップロードする際の、画像ファイルの一辺の最大解像度。
  91. * @constant
  92. */
  93. const MAX_EMOJI_IMAGE_SIZE = 1024;
  94.  
  95. /**
  96. * 絵文字、ステッカーをアップロードする際の、対応画像形式。
  97. * @constant
  98. */
  99. const EMOJI_IMAGE_TYPES = [ 'image/png', 'image/jpeg', 'image/svg+xml' ];
  100.  
  101. /**
  102. * @type {Function}
  103. * @access private
  104. */
  105. let setUserDetails;
  106.  
  107. /**
  108. * @type {Promise.<Object>}
  109. * @access private
  110. */
  111. let userDetails = new Promise(function (resolve) {
  112. let settled = false;
  113. setUserDetails = function (details) {
  114. if (settled) {
  115. userDetails = Promise.resolve(details);
  116. } else {
  117. settled = true;
  118. resolve(details);
  119. }
  120. };
  121. });
  122.  
  123. /**
  124. * キーにワールドIDを持つ連想配列。
  125. * @type {Object.<(string|string[]|boolean|number|Object.<(string|number)>[]|(string|number)[][]|null)>}
  126. */
  127. const worlds = { };
  128.  
  129. /**
  130. * キーにグループIDを持つ連想配列。
  131. * @type {Object.<string,(string|string[]|number|boolean|Object.<string,(string|string[]|boolean)?>)?>[]}
  132. */
  133. const groups = { };
  134.  
  135. addEventListener('message', function (event) {
  136. if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null
  137. || event.data.id !== ID) {
  138. return;
  139. }
  140.  
  141. if (event.data.userDetails) {
  142. setUserDetails(event.data.userDetails);
  143. } else if (event.data.world) {
  144. worlds[event.data.world.id] = event.data.world;
  145. const locations = document.getElementsByClassName('locations')[0];
  146. if (!locations) {
  147. return;
  148. }
  149. for (const [ instanceId ] of event.data.world.instances) {
  150. const locationLink = locations.querySelector(`.locations
  151. [href*=${CSS.escape(`/home/launch?worldId=${event.data.world.id}&instanceId=${instanceId}`)}]`);
  152. if (!locationLink) {
  153. continue;
  154. }
  155. insertInstanceUserCountAndCapacity(locationLink.closest('.locations > *'), event.data.world.id, instanceId);
  156. }
  157. } else if (event.data.group) {
  158. groups[event.data.group.id] = event.data.group;
  159. }
  160. });
  161.  
  162. /**
  163. * ログインしているユーザーの情報を取得します。
  164. * @see [User Info — VRChat API Documentation]{@link https://vrchatapi.github.io/#/UserAPI/CurrentUserDetails}
  165. * @returns {Promise.<?Object.<(string|string[]|boolean|number|Object)>>}
  166. */
  167. async function getUserDetails()
  168. {
  169. return await userDetails;
  170. }
  171.  
  172. /**
  173. * JSONファイルをオブジェクトとして取得します。
  174. * @param {string} url
  175. * @returns {Promise.<(Object|Array)>} OKステータスでなければ失敗します。
  176. */
  177. async function fetchJSON(url)
  178. {
  179. const response = await fetch(url, {credentials: 'same-origin'});
  180. return response.ok
  181. ? response.json()
  182. : Promise.reject(new Error(`${response.status} ${response.statusText}\n${await response.text()}`));
  183. }
  184.  
  185. let friendFavoriteGroupNameDisplayNamePairs;
  186.  
  187. /**
  188. * ログインしているユーザーのフレンドfavoriteのグループ名を取得します。
  189. * @returns {Promise.<Object.<string>[]>}
  190. */
  191. function getFriendFavoriteGroupNameDisplayNamePairs()
  192. {
  193. if (!friendFavoriteGroupNameDisplayNamePairs) {
  194. friendFavoriteGroupNameDisplayNamePairs
  195. = fetchJSON('/api/1/favorite/groups?type=friend', {credentials: 'same-origin'}).then(function (groups) {
  196. const groupNameDisplayNamePairs = {};
  197. for (const group of groups) {
  198. groupNameDisplayNamePairs[group.name] = group.displayName;
  199. }
  200. return groupNameDisplayNamePairs;
  201. });
  202. }
  203. return friendFavoriteGroupNameDisplayNamePairs;
  204. }
  205.  
  206. /**
  207. * @type {Promise.<Object.<(string|string[])>[]>}
  208. * @access private
  209. */
  210. let friendFavoritesPromise;
  211.  
  212. /**
  213. * ブックマークを全件取得します。
  214. * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites}
  215. * @returns {Promise.<Object.<(string|string[])>[]>}
  216. */
  217. function getFriendFavorites()
  218. {
  219. return friendFavoritesPromise || (friendFavoritesPromise = async function () {
  220. const allFavorites = [];
  221. let offset = 0;
  222.  
  223. while (true) { //eslint-disable-line no-constant-condition
  224. const favorites = await fetchJSON(
  225. `/api/1/favorites/?type=friend&n=${MAX_ITEMS_COUNT}&offset=${offset}`,
  226. ).catch(showError);
  227.  
  228. allFavorites.push(...favorites);
  229.  
  230. if (favorites.length < MAX_ITEMS_COUNT) {
  231. break;
  232. }
  233.  
  234. offset += favorites.length;
  235. }
  236. return allFavorites;
  237. }());
  238. }
  239.  
  240. /**
  241. * 自分のユーザーページの編集ダイアログのステータスメッセージ入力欄へ履歴を追加します。
  242. * @returns {Promise.<void>}
  243. */
  244. async function insertStatusMessageHistory()
  245. {
  246. if (document.getElementById('input-status-message-history')) {
  247. // すでに挿入済みなら
  248. return;
  249. }
  250.  
  251. const inputStatusMessage = document.getElementById('input-status-message');
  252. if (!inputStatusMessage) {
  253. return;
  254. }
  255.  
  256. // ステータスメッセージ入力欄へ履歴を追加
  257. inputStatusMessage.insertAdjacentHTML('afterend', `<datalist id="input-status-message-history">
  258. ${(await getUserDetails())
  259. .statusHistory.map(statusDescription => h`<option>${statusDescription}</option>`).join('')}
  260. </datalist>`);
  261. inputStatusMessage.setAttribute('list', inputStatusMessage.nextElementSibling.id);
  262. }
  263.  
  264. /**
  265. * フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。
  266. * @returns {Promise.<void>}
  267. */
  268. async function updateFriendFavoriteCounts()
  269. {
  270. const counts = {};
  271. for (const favorite of await getFriendFavorites()) {
  272. for (const tag of favorite.tags) {
  273. if (!(tag in counts)) {
  274. counts[tag] = 0;
  275. }
  276. counts[tag]++;
  277. }
  278. }
  279.  
  280. for (const button of document.getElementsByName('favorite-friend')) {
  281. button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0;
  282. }
  283. }
  284.  
  285. /**
  286. * ユーザーページへブックマーク登録/解除ボタンを追加します。
  287. * @returns {Promise.<void>}
  288. */
  289. async function insertFriendFavoriteButtons()
  290. {
  291. const homeContent = document.getElementsByClassName('home-content')[0];
  292. const unfriendButton = homeContent.querySelector('[aria-label="Unfriend"]');
  293. if (!unfriendButton) {
  294. return;
  295. }
  296.  
  297. const id = getUserIdFromLocation();
  298. if (!id) {
  299. return;
  300. }
  301.  
  302. const buttons = document.getElementsByName('favorite-friend');
  303. const groupNameDisplayNamePairs = await getFriendFavoriteGroupNameDisplayNamePairs();
  304. const groupNames = Object.keys(groupNameDisplayNamePairs);
  305. const buttonsParent = buttons[0] && buttons[0].closest('[role="group"]');
  306. if (buttonsParent) {
  307. // 多重挿入の防止
  308. if (buttonsParent.dataset.id === id) {
  309. return;
  310. } else {
  311. buttonsParent.remove();
  312. }
  313. }
  314. unfriendButton.parentElement.parentElement.parentElement.parentElement.nextElementSibling.firstElementChild
  315. .insertAdjacentHTML('beforeend', `<div role="group" class="mx-2 btn-group-lg btn-group-vertical"
  316. style="margin-top: -60px;"
  317. data-id="${h(id)}">
  318. ${groupNames.sort().map(tag => h`<button type="button"
  319. class="btn btn-secondary" name="favorite-friend" value="${tag}" disabled="">
  320. <span aria-hidden="true" class="fa fa-star"></span>
  321. &#xA0;<span class="name">${groupNameDisplayNamePairs[tag]}</span>
  322. &#xA0;<span class="count">‒</span>⁄${MAX_FRIEND_FAVORITE_COUNT_PER_GROUP}
  323. </button>`).join('')}
  324. </div>`);
  325.  
  326. await updateFriendFavoriteCounts();
  327.  
  328. const tags = [].concat(
  329. ...(await getFriendFavorites()).filter(favorite => favorite.favoriteId === id).map(favorite => favorite.tags),
  330. );
  331.  
  332. for (const button of buttons) {
  333. button.dataset.id = id;
  334. if (tags.includes(button.value)) {
  335. button.classList.remove('btn-secondary');
  336. button.classList.add('btn-primary');
  337. }
  338. if (button.classList.contains('btn-primary')
  339. || button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
  340. button.disabled = false;
  341. }
  342. }
  343.  
  344. buttons[0].closest('[role="group"]').addEventListener('click', async function (event) {
  345. const button = event.target.closest('button');
  346. if (!button || button.name !== 'favorite-friend') {
  347. return;
  348. }
  349.  
  350. const buttons = document.getElementsByName('favorite-friend');
  351. for (const button of buttons) {
  352. button.disabled = true;
  353. }
  354.  
  355. const id = button.dataset.id;
  356. const newTags = button.classList.contains('btn-secondary') ? [button.value] : [];
  357.  
  358. const favorites = await getFriendFavorites();
  359. for (let i = favorites.length - 1; i >= 0; i--) {
  360. if (favorites[i].favoriteId === id) {
  361. await fetch(
  362. '/api/1/favorites/' + favorites[i].id,
  363. {method: 'DELETE', credentials: 'same-origin'},
  364. );
  365.  
  366. for (const button of buttons) {
  367. if (favorites[i].tags.includes(button.value)) {
  368. button.classList.remove('btn-primary');
  369. button.classList.add('btn-secondary');
  370. }
  371. }
  372.  
  373. favorites.splice(i, 1);
  374. }
  375. }
  376.  
  377. if (newTags.length > 0) {
  378. await fetch('/api/1/favorites', {
  379. method: 'POST',
  380. headers: { 'content-type': 'application/json' },
  381. credentials: 'same-origin',
  382. body: JSON.stringify({type: 'friend', favoriteId: id, tags: newTags}),
  383. })
  384. .then(async response => response.ok ? response.json() : Promise.reject(
  385. new Error(`${response.status} ${response.statusText}\n${await response.text()}`),
  386. ))
  387. .then(function (favorite) {
  388. favorites.push(favorite);
  389. for (const button of buttons) {
  390. if (favorite.tags.includes(button.value)) {
  391. button.classList.remove('btn-secondary');
  392. button.classList.add('btn-primary');
  393. }
  394. }
  395. })
  396. .catch(showError);
  397. }
  398.  
  399. await updateFriendFavoriteCounts();
  400.  
  401. for (const button of buttons) {
  402. if (button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
  403. button.disabled = false;
  404. }
  405. }
  406. });
  407. }
  408.  
  409. /**
  410. * ログイン中のユーザーのグループ一覧。
  411. * @type {(string|boolean|number)[]?}
  412. */
  413. let authUserGroups;
  414.  
  415. /**
  416. * 指定したユーザーが参加しているグループを取得します。
  417. * @param {*} userId
  418. * @returns {Promise.<(string|boolean|number)[]>}
  419. */
  420. function fetchUserGroups(userId)
  421. {
  422. return fetchJSON(`https://vrchat.com/api/1/users/${userId}/groups`);
  423. }
  424.  
  425. /**
  426. * {@link location} からユーザーIDを抽出します。
  427. * @see {@link https://github.com/vrcx-team/VRCX/issues/429#issuecomment-1302920703}
  428. * @returns {string?}
  429. */
  430. function getUserIdFromLocation()
  431. {
  432. 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})/
  433. .exec(location.pathname)?.[1];
  434. }
  435.  
  436. /**
  437. * ユーザーページへブグループへのinviteボタンを追加します。
  438. * @returns {void}
  439. */
  440. function insertInvitingToGroupButton()
  441. {
  442. const userId = getUserIdFromLocation();
  443. if (!userId) {
  444. return;
  445. }
  446.  
  447. const groupsHeading = Array.from(document.querySelectorAll('.home-content h2'))
  448. .find(heading => heading.lastChild?.data === '\'s Groups');
  449. if (!groupsHeading) {
  450. return;
  451. }
  452.  
  453. if (document.getElementsByName('open-inviting-to-group')[0]) {
  454. return;
  455. }
  456.  
  457. const displayName = document.querySelector('.home-content h2').textContent;
  458.  
  459. /*eslint-disable max-len */
  460. groupsHeading.insertAdjacentHTML('beforeend', h`
  461. <button type="button" name="open-inviting-to-group" class="btn btn-primary">
  462. <svg aria-hidden="true" class="svg-inline--fa fa-envelope" role="presentation"
  463. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
  464. <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>
  465. </svg>
  466. Invite to Group
  467. </button>
  468. <div id="user-page-inviting-to-group-dialog" tabindex="-1" hidden=""
  469. style="font-size: 1rem; line-height: initial; position: relative; z-index: 1050; display: block;"><div>
  470. <div class="modal fade show" style="display: block;" role="dialog" tabindex="-1">
  471. <div class="modal-dialog" role="document"><div class="modal-content">
  472. <div class="modal-header">
  473. <h5 class="modal-title"><h4 class="m-0">Invite to Group</h4></h5>
  474. <div><button name="close-inviting-to-group-dialog" aria-label="Close Button"
  475. style="padding: 5px; border-radius: 4px; border: 2px solid #333333; background: #333333;
  476. color: white;">
  477. <svg role="presentation" aria-hidden="true" class="svg-inline--fa fa-xmark fa-fw"
  478. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" width="20">
  479. <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>
  480. </svg>
  481. </button></div>
  482. </div>
  483. <div class="modal-body"></div>
  484. </div></div>
  485. </div>
  486. <div class="modal-backdrop fade show"></div>
  487. </div></div>
  488. `);
  489. /*eslint-enable max-len */
  490.  
  491. const dialog = document.getElementById('user-page-inviting-to-group-dialog');
  492.  
  493. groupsHeading.addEventListener('click', async function (event) {
  494. const button = event.target.closest('button');
  495. if (!button) {
  496. return;
  497. }
  498.  
  499. switch (button.name) {
  500. case 'open-inviting-to-group': {
  501. dialog.hidden = false;
  502.  
  503. const modalBody = dialog.getElementsByClassName('modal-body')[0];
  504. if (modalBody.firstElementChild) {
  505. break;
  506. }
  507.  
  508. if (!authUserGroups) {
  509. authUserGroups = await fetchUserGroups((await getUserDetails()).id);
  510. }
  511. const groupIds
  512. = Array.from(groupsHeading.nextElementSibling.querySelectorAll('[aria-label="Group Card"]'))
  513. .map(groupCard => /grp_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
  514. .exec(groupCard.pathname)[0]);
  515.  
  516. if (!document.getElementById('invite-to-group-style')) {
  517. document.head.insertAdjacentHTML('beforeend', `<style id="invite-to-group-style">
  518. [name="invite-to-group"] {
  519. --icon-size: 30px;
  520. --padding: 5px;
  521. padding: var(--padding) calc(var(--icon-size) + 2 * var(--padding));
  522. font-size: 1.2em;
  523. border: 2px solid #064B5C;
  524. border-radius: 4px;
  525. position: relative;
  526. color: #6AE3F9;
  527. background: #064B5C;
  528. overflow: hidden;
  529. text-overflow: ellipsis;
  530. white-space: nowrap;
  531. width: 100%;
  532. }
  533.  
  534. [name="invite-to-group"]:hover {
  535. border-color: #086C84;
  536. }
  537.  
  538. [name="invite-to-group"]:disabled {
  539. border: 2px solid #333333;
  540. background: #333333;
  541. color: #999999;
  542. }
  543.  
  544. [name="invite-to-group"] img {
  545. width: var(--icon-size);
  546. height: var(--icon-size);
  547. border-radius: 100%;
  548. border: 1px solid #181B1F;
  549. background-color: #181B1F;
  550. position: absolute;
  551. left: var(--padding);
  552. }
  553.  
  554. [role="alert"] {
  555. display: flex;
  556. flex-direction: column;
  557. background-color: #541D22BF;
  558. margin-top: 10px;
  559. border-radius: 3px;
  560. padding: 10px;
  561. border-left: 3px solid red;
  562. }
  563.  
  564. [role="alert"] > div:first-of-type {
  565. display: flex;
  566. align-items: center;
  567. }
  568.  
  569. [role="alert"] > div:first-of-type > div:first-of-type {
  570. font-size: 1.2rem;
  571. font-weight: bold;
  572. }
  573. </style>`);
  574. }
  575. /*eslint-disable indent */
  576. modalBody.innerHTML = authUserGroups.map(group => h`<div
  577. class="mt-2 mb-2 d-flex flex-column justify-content-center">
  578. <div style="position: relative; border-radius: 4px;">
  579. <button name="invite-to-group" value="${h(group.groupId)}"` + (groupIds.includes(group.groupId)
  580. ? h` disabled="" title="${displayName} is already a member of this group․"`
  581. : '') + h`>
  582. <img src="${group.iconUrl}">
  583. ${group.name}
  584. </button>
  585. </div>
  586. </div>`).join('');
  587. /*eslint-enable indent */
  588. break;
  589. }
  590. case 'invite-to-group': {
  591. const enabledButtons = Array.from(dialog.querySelectorAll('button:enabled'));
  592. try {
  593. for (const button of enabledButtons) {
  594. button.disabled = true;
  595. }
  596.  
  597. const response = await fetch(`/api/1/groups/${button.value}/invites`, {
  598. method: 'POST',
  599. headers: { 'content-type': 'application/json' },
  600. credentials: 'same-origin',
  601. body: JSON.stringify({ userId, confirmOverrideBlock: true }),
  602. });
  603. if (!response.ok) {
  604. const { error: { message } } = await response.json();
  605. /*eslint-disable max-len */
  606. button.parentElement.insertAdjacentHTML('beforebegin', h`<div role="alert"
  607. aria-label="Couldn't invite user">
  608. <div>
  609. <svg aria-hidden="true" class="svg-inline--fa fa-circle-exclamation me-2"
  610. role="presentation"
  611. xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" color="red">
  612. <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>
  613. </svg>
  614. <div>Couldn't invite user</div>
  615. </div>
  616. <div>${response.statusText}: ${message}</div>
  617. </div>`);
  618. /*eslint-enable max-len */
  619. }
  620. enabledButtons.splice(enabledButtons.indexOf(button), 1);
  621. } finally {
  622. for (const button of enabledButtons) {
  623. button.disabled = false;
  624. }
  625. }
  626. break;
  627. }
  628. case 'close-inviting-to-group-dialog':
  629. dialog.hidden = true;
  630. break;
  631. }
  632. });
  633. }
  634.  
  635. /**
  636. * Friend Locationsページのインスタンスへ、現在のインスタンス人数と上限を表示します。
  637. * @param {HTMLDivElement} location
  638. * @returns {void}
  639. */
  640. function insertInstanceUserCountAndCapacity(location, worldId, instanceId)
  641. {
  642. const world = worlds[worldId];
  643. const instanceUserCount = world?.instances?.find(([ id ]) => id === instanceId)?.[1];
  644.  
  645. /** @type {HTMLElement} */
  646. let counts = location.getElementsByClassName('instance-user-count-and-capacity')[0];
  647. if (!counts) {
  648. const button = location.querySelector('[aria-label="Invite Me"]');
  649. const friendCount = location.querySelector('[aria-label="Invite Me"]').parentElement.previousElementSibling;
  650. counts = friendCount.cloneNode();
  651. counts.classList.add('instance-user-count-and-capacity');
  652. const reloadButton = button.cloneNode();
  653. reloadButton.setAttribute('aria-label', 'Reload');
  654. reloadButton.textContent = '↺';
  655. reloadButton.addEventListener('click', async function (event) {
  656. const instance
  657. = await fetchJSON(`/api/1/instances/${worldId}:${instanceId}`, { credentials: 'same-origin' });
  658. event.target.previousSibling.data = instance.userCount + ' / ' + instance.capacity;
  659. });
  660. counts.append('', reloadButton);
  661. friendCount.before(counts);
  662. }
  663. counts.firstChild.data = (instanceUserCount ?? '?') + ' / ' + (world?.capacity ?? '?');
  664. }
  665.  
  666. /**
  667. * ギャラリーのアップローダーの挙動を改善します。
  668. * @remarks すでに改善済みの場合は何もしません。
  669. * @param {object} args
  670. * @param {HTMLInputElement} args.inputElement - `type="file"`。
  671. * @param {HTMLElement} args.dropZoneElement
  672. * @param {number} args.maxImageSize - 画像ファイルの一辺の最大解像度。
  673. * @param {number} args.aspectRatio - 出力画像のアスペクト比 (幅 `/` 高さ)。
  674. */
  675. function fixGallaryUploader({
  676. inputElement,
  677. dropZoneElement = null,
  678. maxImageSize,
  679. aspectRatio,
  680. })
  681. {
  682. if (inputElement.accept.includes('.webp')) {
  683. return;
  684. }
  685.  
  686. // WebPファイルを選択可能に
  687. inputElement.accept += ',.webp';
  688.  
  689. /**
  690. * 変換済みのファイル。
  691. * @type {WeakSet.<File>}
  692. */
  693. const alreadyConvertedFiles = new WeakSet();
  694.  
  695. // ファイル選択ダイアログによる画像ファイルの選択をトラップ
  696. inputElement.addEventListener('change', function (event) {
  697. const selectedFile = inputElement.files[0];
  698.  
  699. if (!selectedFile || alreadyConvertedFiles.has(selectedFile) || !selectedFile.type.startsWith('image/')) {
  700. // ファイルが選択されていない、ファイル変換後に発生させたイベント、または画像ファイル以外が選択されていれば
  701. return;
  702. }
  703.  
  704. event.stopPropagation();
  705.  
  706. // 画像ファイルを変換し、再セット
  707. convertImageFileForEmojiUploaderAndChangeInputElement({
  708. alreadyConvertedFiles,
  709. file: selectedFile,
  710. inputElement,
  711. maxImageSize,
  712. aspectRatio,
  713. });
  714. }, true);
  715.  
  716. if (dropZoneElement) {
  717. // ドロップゾーンへのファイルのドロップをトラップ
  718. dropZoneElement.addEventListener('drop', function (event) {
  719. const droppedFile = event.dataTransfer.files[0];
  720. if (!droppedFile || !droppedFile.type.startsWith('image/')) {
  721. // ファイルがドロップされていない、または画像ファイル以外がドロップされていれば
  722. return;
  723. }
  724.  
  725. event.preventDefault();
  726. event.stopPropagation();
  727.  
  728. // 画像ファイルを変換、input要素へセット
  729. convertImageFileForEmojiUploaderAndChangeInputElement({
  730. alreadyConvertedFiles,
  731. file: droppedFile,
  732. inputElement,
  733. maxImageSize,
  734. aspectRatio,
  735. });
  736. });
  737. }
  738. }
  739.  
  740. /**
  741. * 画像ファイルを次のように変換し、指定されたinput要素へセットし直します。
  742. * - 一辺 `maxImageSize` 以内に収まるように
  743. * - 指定されたアスペクト比に合わせて透明な余白を追加
  744. * - {@link EMOJI_IMAGE_TYPES} 以外の形式、あるいは他の条件が満たされていなければ、PNGへ変換
  745. * @param {object} args
  746. * @param {WeakSet.<File>} args.alreadyConvertedFiles
  747. * @param {File} args.file - 画像ファイル。
  748. * @param {HTMLInputElement} args.input - `type="file"`。
  749. * @param {number} args.maxImageSize - 画像ファイルの一辺の最大解像度。
  750. * @param {number} args.aspectRatio - 出力画像のアスペクト比 (幅 `/` 高さ)。
  751. * @returns {Promise.<void>}
  752. */
  753. async function convertImageFileForEmojiUploaderAndChangeInputElement({
  754. alreadyConvertedFiles,
  755. file,
  756. inputElement,
  757. maxImageSize,
  758. aspectRatio,
  759. })
  760. {
  761. // 画像ファイルの読み込み
  762. const img = new Image();
  763. const reader = new FileReader();
  764. await new Promise(function (resolve) {
  765. img.addEventListener('load', function () {
  766. resolve();
  767. });
  768.  
  769. reader.addEventListener('load', function (event) {
  770. img.src = event.target.result;
  771. });
  772. reader.readAsDataURL(file);
  773. });
  774.  
  775. if (img.width < img.height && aspectRatio > 1) {
  776. // 縦長画像が横長アスペクト比の出力に指定されている場合
  777. // 回転用のcanvasを作成
  778. const canvas = document.createElement('canvas');
  779. canvas.width = img.height; // 90度回転後の幅
  780. canvas.height = img.width; // 90度回転後の高さ
  781.  
  782. // 画像を右に90度回転して描画
  783. const ctx = canvas.getContext('2d');
  784. ctx.translate(canvas.width / 2, canvas.height / 2);
  785. ctx.rotate(Math.PI / 2);
  786. ctx.drawImage(img, -img.width / 2, -img.height / 2);
  787.  
  788. // 回転後の画像へ更新
  789. img.src = canvas.toDataURL();
  790. await new Promise(function (resolve) {
  791. img.addEventListener('load', resolve);
  792. });
  793. }
  794.  
  795. if (img.width / img.height === aspectRatio
  796. && img.width <= maxImageSize && img.height <= maxImageSize
  797. && (EMOJI_IMAGE_TYPES.includes(file.type))) {
  798. // 指定されたアスペクト比、最大解像度以内、かつ対応形式なら
  799. // そのまま返す
  800. return file;
  801. }
  802.  
  803. let width = img.width;
  804. let height = img.height;
  805.  
  806. if (width > maxImageSize || height > maxImageSize) {
  807. // 最大解像度を超えていれば
  808. // アスペクト比を維持してリサイズする場合の解像度を算出
  809. if (width > height) {
  810. height = Math.round(height * (maxImageSize / width));
  811. width = maxImageSize;
  812. } else {
  813. width = Math.round(width * (maxImageSize / height));
  814. height = maxImageSize;
  815. }
  816. }
  817.  
  818. // 画像全体が収まる解像度のcanvasを作成
  819. const canvas = document.createElement('canvas');
  820. /** @type {number} */
  821. let canvasWidth;
  822. /** @type {number} */
  823. let canvasHeight;
  824. if (aspectRatio > 1) {
  825. canvasWidth = maxImageSize;
  826. canvasHeight = maxImageSize / aspectRatio;
  827. } else {
  828. canvasWidth = maxImageSize * aspectRatio;
  829. canvasHeight = maxImageSize;
  830. }
  831. canvas.width = canvasWidth;
  832. canvas.height = canvasHeight;
  833.  
  834. // 中央に画像を描画
  835. let drawWidth, drawHeight, offsetX, offsetY;
  836. if (width / height > aspectRatio) {
  837. // 横長の画像の場合
  838. drawWidth = canvasWidth;
  839. drawHeight = canvasWidth / (width / height);
  840. offsetX = 0;
  841. offsetY = (canvasHeight - drawHeight) / 2;
  842. } else {
  843. // 縦長の画像の場合
  844. drawHeight = canvasHeight;
  845. drawWidth = canvasHeight * (width / height);
  846. offsetX = (canvasWidth - drawWidth) / 2;
  847. offsetY = 0;
  848. }
  849. canvas.getContext('2d').drawImage(
  850. img,
  851. offsetX,
  852. offsetY,
  853. drawWidth,
  854. drawHeight,
  855. );
  856.  
  857. // PNG画像として出力し、新しい File オブジェクトを作成
  858. const processedFile = new File([ await new Promise(function (resolve) {
  859. canvas.toBlob(resolve, 'image/png');
  860. }) ], 'image.png', { type: 'image/png' });
  861.  
  862. // 変換済みのファイルを記録
  863. alreadyConvertedFiles.add(processedFile);
  864.  
  865. // 選択されたファイルを置き換える
  866. const dataTransfer = new DataTransfer();
  867. dataTransfer.items.add(processedFile);
  868. inputElement.files = dataTransfer.files;
  869. inputElement.dispatchEvent(new Event('change', { bubbles: true }));
  870. }
  871.  
  872. /**
  873. * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。
  874. * @type {boolean}
  875. * @access private
  876. */
  877. let headChildrenInserted = false;
  878.  
  879. const homeContents = document.getElementsByClassName('home-content');
  880.  
  881. new MutationObserver(function (mutations, observer) {
  882. if (document.head && !headChildrenInserted) {
  883. headChildrenInserted = true;
  884. document.head.insertAdjacentHTML('beforeend', `<style>
  885. /*====================================
  886. Friend Locations
  887. */
  888. .instance-user-count-and-capacity {
  889. white-space: nowrap;
  890. }
  891.  
  892. .instance-user-count-and-capacity button {
  893. margin: 0 0 0 0.5em !important;
  894. padding: unset;
  895. line-height: 1;
  896. }
  897.  
  898. /*====================================
  899. フレンドのユーザーページ
  900. */
  901. .btn[name^="favorite-"] {
  902. white-space: unset;
  903. }
  904. </style>`);
  905.  
  906. // ユーザー情報・ワールド情報・グループ情報を取得
  907. GreasemonkeyUtils.executeOnUnsafeContext(function (id) {
  908. Response.prototype.text = new Proxy(Response.prototype.text, {
  909. apply(get, thisArgument, argumentList)
  910. {
  911. const textPromise = Reflect.apply(get, thisArgument, argumentList);
  912. (async function () {
  913. const data = { id };
  914. const pathname = new URL(thisArgument.url).pathname;
  915. if (pathname === '/api/1/auth/user') {
  916. data.userDetails = JSON.parse(await textPromise);
  917. } else if (pathname.startsWith('/api/1/worlds/wrld_')) {
  918. data.world = JSON.parse(await textPromise);
  919. } else if (pathname.startsWith('/api/1/groups/grp_')) {
  920. data.group = JSON.parse(await textPromise);
  921. } else {
  922. return;
  923. }
  924. postMessage(data, location.origin);
  925. })();
  926. return textPromise;
  927. },
  928. });
  929. }, [ ID ]);
  930. }
  931.  
  932. if (!homeContents[0]) {
  933. return;
  934. }
  935.  
  936. const locationsList = homeContents[0].getElementsByClassName('locations');
  937. const instanceUserCountAndCapacityList = homeContents[0].getElementsByClassName('instance-user-count-and-capacity');
  938.  
  939. new MutationObserver(async function (mutations) {
  940. if (location.pathname === '/home/uploadPhoto') {
  941. // Photosのアップロードページ
  942. const inputElement = document.querySelector('[type="file"]');
  943. fixGallaryUploader({
  944. inputElement,
  945. maxImageSize: 2048,
  946. aspectRatio: 16 / 9,
  947. });
  948. return;
  949. }
  950.  
  951. for (const mutation of mutations) {
  952. if (locationsList[0]) {
  953. if (locationsList[0].children.length !== instanceUserCountAndCapacityList.length) {
  954. // Friend Locationsへインスタンス人数を追加
  955. for (const location of locationsList[0].children) {
  956. if (location.getElementsByClassName('instance-user-count-and-capacity')[0]) {
  957. continue;
  958. }
  959.  
  960. const launchLink = location.querySelector('[href*="/home/launch?"]');
  961. if (!launchLink) {
  962. continue;
  963. }
  964. const params = new URLSearchParams(launchLink.search);
  965. insertInstanceUserCountAndCapacity(location, params.get('worldId'), params.get('instanceId'));
  966. }
  967. }
  968. } else if (mutation.addedNodes.length > 0 && mutation.target.nodeType === Node.ELEMENT_NODE
  969. && (/* ユーザーページを開いたとき */ mutation.target.classList.contains('home-content')
  970. || mutation.target.localName === 'div'
  971. && mutation.addedNodes.length === 1 && mutation.addedNodes[0].localName === 'div'
  972. && mutation.addedNodes[0]
  973. .querySelector('[aria-label="Add Friend"], [aria-label="Unfriend"]')
  974. || /* ワールドページを開いたとき */ mutation.target.parentElement.classList.contains('home-content')
  975. || /* グループページでタブ移動したとき */ mutation.target.parentElement.parentElement
  976. .classList.contains('home-content'))
  977. || /* ユーザーページ間を移動したとき */ mutation.type === 'characterData'
  978. && mutation.target.nextSibling?.data === '\'s Profile') {
  979. if (location.pathname.startsWith('/home/user/')) {
  980. // ユーザーページ
  981. await insertStatusMessageHistory();
  982. insertInvitingToGroupButton();
  983. await insertFriendFavoriteButtons('friend');
  984. } else if (location.pathname.startsWith('/home/world/')) {
  985. // ワールドページ
  986. const heading = document.querySelector('.home-content h2');
  987. const name = heading.firstChild.data;
  988. const author
  989. = heading.nextElementSibling.querySelector('[href^="/home/user/"]').firstChild.data;
  990. document.title = `${name} By ${author} - VRChat`;
  991. } else if (location.pathname.startsWith('/home/avatar/')) {
  992. // アバターページ
  993. const name = document.querySelector('.home-content h3').textContent;
  994. const author = document.querySelector('.home-content [href^="/home/user/"]').text;
  995. document.title = `${name} By ${author} - VRChat`;
  996. } else if (location.pathname.startsWith('/home/group/')) {
  997. // グループページ
  998. const name = document.querySelector('.home-content h2').textContent;
  999. const groupLink = document.querySelector('[href^="https://vrc.group/"]');
  1000. const shortCodeAndDiscriminator = groupLink.textContent;
  1001. document.title = `${name} ${shortCodeAndDiscriminator} - VRChat`;
  1002.  
  1003. // グループオーナーへのリンクを追加
  1004. setTimeout(function () {
  1005. if (!document.getElementById('group-owner-link')) {
  1006. const groupLinkColumn = groupLink.closest('div');
  1007. groupLinkColumn.style.marginLeft = '1em';
  1008. const column = groupLinkColumn.cloneNode();
  1009. const ownerId = groups[/^\/home\/group\/([^/]+)/.exec(location.pathname)[1]].ownerId;
  1010. column.innerHTML = h`<a id="group-owner-link" href="/home/user/${ownerId}">
  1011. Group Owner
  1012. </a>`;
  1013. groupLinkColumn.after(column);
  1014. }
  1015. });
  1016. }
  1017. break;
  1018. }
  1019. }
  1020. }).observe(homeContents[0], {childList: true, characterData: true, subtree: true });
  1021.  
  1022. // 絵文字、もしくはステッカーのアップロードページ
  1023. new MutationObserver(function () {
  1024. if (![ '/home/gallery/emoji', '/home/gallery/stickers' ].includes(location.pathname)) {
  1025. return;
  1026. }
  1027.  
  1028. const inputElement = document.getElementById('file');
  1029. if (!inputElement) {
  1030. return;
  1031. }
  1032.  
  1033. fixGallaryUploader({
  1034. inputElement,
  1035. dropZoneElement: inputElement.parentElement.parentElement.parentElement,
  1036. maxImageSize: MAX_EMOJI_IMAGE_SIZE,
  1037. aspectRatio: 1 / 1,
  1038. });
  1039. }).observe(document.getElementById('home'), {childList: true});
  1040.  
  1041. observer.disconnect();
  1042. }).observe(document, {childList: true, subtree: true});