VRChat Web Pages Extender

Add features into VRChat Web Pages and improve user experience.

目前为 2021-01-31 提交的版本。查看 最新版本

  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.7.0
  8. // @match https://www.vrchat.com/*
  9. // @match https://vrchat.com/*
  10. // @match https://api.vrchat.cloud/*
  11. // @require https://greasyfork.org/scripts/19616/code/utilities.js?version=895049
  12. // @license MPL-2.0
  13. // @contributionURL https://pokemori.booth.pm/items/969835
  14. // @compatible Edge
  15. // @compatible Firefox Firefoxを推奨 / Firefox is recommended
  16. // @compatible Opera
  17. // @compatible Chrome
  18. // @grant dummy
  19. // @run-at document-start
  20. // @icon https://images.squarespace-cdn.com/content/v1/5f0770791aaf57311515b23d/1599678606410-4QMTB25DHF87E8EFFKXY/ke17ZwdGBToddI8pDm48kGfiFqkITS6axXxhYYUCnlRZw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpxQ1ibo-zdhORxWnJtmNCajDe36aQmu-4Z4SFOss0oowgxUaachD66r8Ra2gwuBSqM/favicon.ico
  21. // @author 100の人
  22. // @homepageURL https://pokemori.booth.pm/items/969835
  23. // ==/UserScript==
  24.  
  25. 'use strict';
  26.  
  27. // L10N
  28. Gettext.setLocalizedTexts({
  29. /*eslint-disable quote-props, max-len */
  30. 'en': {
  31. 'エラーが発生しました': 'Error occurred',
  32. '$LENGTH$ 文字まで表示可能です。': 'This text is displayed up to $LENGTH$ characters.',
  33. },
  34. /*eslint-enable quote-props, max-len */
  35. });
  36.  
  37. Gettext.setLocale(navigator.language);
  38.  
  39.  
  40.  
  41. if (typeof content !== 'undefined') {
  42. // For Greasemonkey 4
  43. fetch = content.fetch.bind(content); //eslint-disable-line no-global-assign, no-native-reassign, no-undef
  44. }
  45.  
  46.  
  47.  
  48. /**
  49. * ページ上部にエラー内容を表示します。
  50. * @param {Error} exception
  51. * @returns {void}
  52. */
  53. function showError(exception)
  54. {
  55. console.error(exception);
  56. try {
  57. const errorMessage = _('エラーが発生しました') + ': ' + exception
  58. + ('stack' in exception ? '\n\n' + exception.stack : '');
  59. const homeContent = document.getElementsByClassName('home-content')[0];
  60. if (homeContent) {
  61. homeContent.firstElementChild.firstElementChild.insertAdjacentHTML('afterbegin', h`<div class="row">
  62. <div class="alert alert-danger fade show" role="alert"
  63. style="white-space: pre-wrap; font-size: 1rem; font-weight: normal;">${errorMessage}</div>
  64. </div>`);
  65. } else {
  66. alert(errorMessage);
  67. }
  68. } catch (e) {
  69. alert(_('エラーが発生しました') + ': ' + e);
  70. }
  71. }
  72.  
  73. const ID = 'vrchat-web-pages-extender-137';
  74.  
  75. /**
  76. * 一度に取得できる最大の要素数。
  77. * @constant {number}
  78. */
  79. const MAX_ITEMS_COUNT = 100;
  80.  
  81. /**
  82. * Statusの種類。
  83. * @constant {number}
  84. */
  85. const STATUSES = {
  86. 'join me': {
  87. label: 'Join Me: Auto-accept join requests.',
  88. color: '--status-joinme',
  89. },
  90. active: {
  91. label: 'Online: See join requests.',
  92. color: '--status-online',
  93. },
  94. 'ask me': {
  95. label: 'Ask Me: Hide location, see join requests.',
  96. color: '--status-askme',
  97. },
  98. busy: {
  99. label: 'Do Not Disturb: Hide location, hide join requests.',
  100. color: '--status-busy',
  101. },
  102. };
  103.  
  104. /**
  105. * Status Descriptionの最大文字数。
  106. * @constant {number}
  107. */
  108. const MAX_STATUS_DESCRIPTION_LENGTH = 32;
  109.  
  110. /**
  111. * 一つのブックマークグループの最大登録数。
  112. * @constant {number}
  113. */
  114. const MAX_FAVORITES_COUNT_PER_GROUP = 32;
  115.  
  116. /**
  117. * @type {Function}
  118. * @access private
  119. */
  120. let resolveUserDetails;
  121.  
  122. /**
  123. * @type {Promise.<Object>}
  124. * @access private
  125. */
  126. const userDetails = new Promise(function (resolve) {
  127. resolveUserDetails = resolve;
  128. });
  129.  
  130. addEventListener('message', function receiveUserDetails(event) {
  131. if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null
  132. || event.data.id !== ID || !event.data.userDetails) {
  133. return;
  134. }
  135. event.currentTarget.removeEventListener(event.type, receiveUserDetails);
  136.  
  137. resolveUserDetails(event.data.userDetails);
  138. });
  139.  
  140. /**
  141. * ログインしているユーザーの情報を取得します。
  142. * @see [User Info — VRChat API Documentation]{@link https://vrchatapi.github.io/#/UserAPI/CurrentUserDetails}
  143. * @returns {Promise.<?Object.<(string|string[]|boolean|number|Object)>>}
  144. */
  145. async function getUserDetails()
  146. {
  147. return await userDetails;
  148. }
  149.  
  150. /**
  151. * JSONファイルをオブジェクトとして取得します。
  152. * @param {string} url
  153. * @returns {Promise.<(Object|Array)>} OKステータスでなければ失敗します。
  154. */
  155. async function fetchJSON(url)
  156. {
  157. const response = await fetch(url, {credentials: 'same-origin'});
  158. return response.ok
  159. ? response.json()
  160. : Promise.reject(new Error(`${response.status} ${response.statusText}\n${await response.text()}`));
  161. }
  162.  
  163. /**
  164. * スクリプトで扱うブックマークの種類。
  165. * @constant {string[]}
  166. */
  167. const FAVORITE_TYPES = ['friend', 'world'];
  168.  
  169. let favoriteTypeGroupsPairsPromise;
  170.  
  171. /**
  172. * ログインしているユーザーのfavoriteのグループ名を取得します。
  173. * @returns {Object.<Object.<string>[]>} {@link FAVORITE_TYPES}の要素をキーに持つ連想配列。
  174. */
  175. function getFavoriteTypeGroupsPairs()
  176. {
  177. if (!favoriteTypeGroupsPairsPromise) {
  178. favoriteTypeGroupsPairsPromise = fetchJSON('/api/1/favorite/groups', {credentials: 'same-origin'})
  179. .then(function (groups) {
  180. const favoriteTypeGroupsPairs = {};
  181. for (const group of groups.filter(group => FAVORITE_TYPES.includes(group.type))) {
  182. if (!(group.type in favoriteTypeGroupsPairs)) {
  183. favoriteTypeGroupsPairs[group.type] = {};
  184. }
  185. favoriteTypeGroupsPairs[group.type][group.name] = group.displayName;
  186. }
  187. return favoriteTypeGroupsPairs;
  188. });
  189. }
  190. return favoriteTypeGroupsPairsPromise;
  191. }
  192.  
  193. /**
  194. * @type {Promise.<Object.<(string|string[])[]>[]>}
  195. * @access private
  196. */
  197. let favorites;
  198.  
  199. /**
  200. * ブックマークを全件取得します。
  201. * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites}
  202. * @returns {Promise.<Object.<(string|string[])[]>[]>} {@link FAVORITE_TYPES}の要素をキーに持つ連想配列。
  203. */
  204. function getFavorites()
  205. {
  206. return favorites || (favorites = async function () {
  207. const allFavorites = { };
  208. for (const type of FAVORITE_TYPES) {
  209. allFavorites[type] = [];
  210. }
  211. let offset = 0;
  212. while (true) {
  213. const favorites
  214. = await fetchJSON(`/api/1/favorites/?n=${MAX_ITEMS_COUNT}&offset=${offset}`).catch(showError);
  215.  
  216. for (const favorite of favorites) {
  217. if (!FAVORITE_TYPES.includes(favorite.type)) {
  218. continue;
  219. }
  220. allFavorites[favorite.type].push(favorite);
  221. }
  222.  
  223. if (favorites.length < MAX_ITEMS_COUNT) {
  224. break;
  225. }
  226.  
  227. offset += favorites.length;
  228. }
  229. return allFavorites;
  230. }());
  231. }
  232.  
  233. /**
  234. * 「Edit Profile」ページに、ステータス文変更フォームを挿入します。
  235. * @returns {Promise.<void>}
  236. */
  237. async function insertUpdateStatusForm()
  238. {
  239. if ('update-status' in document.forms) {
  240. return;
  241. }
  242.  
  243. const sidebarStatus = document.querySelector('.leftbar .user-info h6 span[title]');
  244. const sidebarStatusDescription = document.querySelector('.leftbar .statusDescription small');
  245.  
  246. const templateCard = document.getElementById('name-change-submit').closest('.card');
  247. const card = templateCard.cloneNode(true);
  248. card.getElementsByClassName('card-header')[0].textContent = 'Status';
  249. const form = card.getElementsByTagName('form')[0];
  250. form.name = 'update-status';
  251. form.action = '/api/1/users/' + document.querySelector('[href*="/home/user/usr_"]').pathname.replace(/.+\//u, '');
  252.  
  253. const description = form.displayName;
  254. description.id = 'status-description';
  255. description.type = 'text';
  256. description.name = 'statusDescription';
  257. description.value = sidebarStatusDescription.textContent;
  258. description.placeholder = '';
  259. description.pattern = `.{0,${MAX_STATUS_DESCRIPTION_LENGTH}}`;
  260. description.title = _('$LENGTH$ 文字まで表示可能です。').replace('$LENGTH$', MAX_STATUS_DESCRIPTION_LENGTH);
  261. description.parentElement.getElementsByClassName('alert')[0].remove();
  262. const descriptionContainer = description.closest('.col-10');
  263. descriptionContainer.classList.replace('col-10', 'col');
  264.  
  265. descriptionContainer.parentElement.classList.add('mb-2');
  266. const statusContainer = descriptionContainer.previousElementSibling;
  267. statusContainer.outerHTML = '<div class="col-auto"><select name="status" class="form-control">'
  268. + Object.keys(STATUSES).map(status => h`<option value="${status}">⬤ ${STATUSES[status].label}</option>`)
  269. .join('')
  270. + '</select></div>';
  271. form.status.value = sidebarStatus.title;
  272. form.status.classList.add(form.status.value.replace(' ', '-'));
  273. form.status.addEventListener('change', function (event) {
  274. const classList = event.target.classList;
  275. classList.remove(...Object.keys(STATUSES).map(status => status.replace(' ', '-')));
  276. classList.add(event.target.value.replace(' ', '-'));
  277. });
  278.  
  279. const submit = form.getElementsByClassName('btn')[0];
  280. submit.id = 'status-change-submit';
  281. submit.textContent = 'Update Status';
  282. submit.disabled = false;
  283. form.addEventListener('submit', function (event) {
  284. event.preventDefault();
  285. for (const control of event.target) {
  286. control.disabled = true;
  287. }
  288.  
  289. const body = {};
  290. for (const element of event.target) {
  291. if (element.localName === 'button') {
  292. continue;
  293. }
  294. body[element.name] = element.value;
  295. }
  296.  
  297. fetch(event.target.action, {
  298. method: 'PUT',
  299. headers: {'content-type': 'application/json'},
  300. credentials: 'same-origin',
  301. body: JSON.stringify(body),
  302. })
  303. .then(async function (response) {
  304. if (!response.ok) {
  305. return Promise.reject(
  306. new Error(`${response.status} ${response.statusText}\n${await response.text()}`)
  307. );
  308. }
  309. sidebarStatus.classList.remove(...Object.keys(STATUSES).map(status => status.replace(' ', '-')));
  310. sidebarStatus.classList.add(event.target.status.value.replace(' ', '-'));
  311. sidebarStatus.title = event.target.status;
  312. sidebarStatusDescription.textContent = event.target.statusDescription.value;
  313. })
  314. .catch(showError)
  315. .then(function () {
  316. for (const control of event.target) {
  317. control.disabled = false;
  318. }
  319. });
  320. });
  321.  
  322. templateCard.parentElement.getElementsByClassName('card')[0].before(card, document.createElement('hr'));
  323. }
  324.  
  325. /**
  326. * ブックマーク登録/解除ボタンの登録数表示を更新します。
  327. * @param {string} type - 「user」「favorite」のいずれか。
  328. * @returns {Promise.<void>}
  329. */
  330. async function updateFavoriteCounts(type)
  331. {
  332. const counts = {};
  333. for (const favorite of (await getFavorites())[type]) {
  334. for (const tag of favorite.tags) {
  335. if (!(tag in counts)) {
  336. counts[tag] = 0;
  337. }
  338. counts[tag]++;
  339. }
  340. }
  341.  
  342. for (const button of document.getElementsByName('favorite-' + type)) {
  343. button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0;
  344. }
  345. }
  346.  
  347. /**
  348. * ブックマーク登録/解除ボタンを追加します。
  349. * @param {string} type - {@link FAVOLITE_TYPES}のいずれかの要素。
  350. * @returns {Promise.<void>}
  351. */
  352. async function insertFavoriteButtons(type)
  353. {
  354. const homeContent = document.getElementsByClassName('home-content')[0];
  355. if (!homeContent.querySelector('[name="type"][value="public"]')) {
  356. // privateワールド
  357. return;
  358. }
  359.  
  360. const sibling = homeContent.querySelector('[role="group"]');
  361. if (!sibling) {
  362. return;
  363. }
  364. const parent = sibling.closest('[class*="col-"]');
  365.  
  366. const result = /[a-z]+_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.exec(location.pathname);
  367. if (!result) {
  368. return;
  369. }
  370. const id = result[0];
  371.  
  372. const buttons = document.getElementsByName('favorite-' + type);
  373. if (type === 'friend' && !(await getUserDetails()).friends.includes(id) || buttons[0]) {
  374. return;
  375. }
  376.  
  377. const groupNameDisplayNamePairs = (await getFavoriteTypeGroupsPairs())[type];
  378. const groupNames = Object.keys(groupNameDisplayNamePairs);
  379. if (parent.querySelector(`[name="favorite-${CSS.escape(type)}"][value="${CSS.escape(groupNames[0])}"]`)) {
  380. // 多重挿入の防止
  381. return;
  382. }
  383. parent.insertAdjacentHTML('beforeend', '<div role="group" class="w-100 btn-group-lg btn-group-vertical mt-4">'
  384. + groupNames.sort().map(tag => h`<button type="button"
  385. class="btn btn-secondary" name="favorite-${type}" value="${tag}" disabled="">
  386. <span aria-hidden="true" class="fa fa-star"></span>
  387. &#xA0;<span class="name">${groupNameDisplayNamePairs[tag]}</span>
  388. &#xA0;<span class="count">‒</span>⁄${MAX_FAVORITES_COUNT_PER_GROUP}
  389. </button>`).join('')
  390. + '</div>');
  391.  
  392. await updateFavoriteCounts(type);
  393.  
  394. const tags = [].concat(...(await getFavorites())[type]
  395. .filter(favorite => favorite.favoriteId === id)
  396. .map(favorite => favorite.tags));
  397.  
  398. for (const button of buttons) {
  399. button.dataset.id = id;
  400. if (tags.includes(button.value)) {
  401. button.classList.remove('btn-secondary');
  402. button.classList.add('btn-primary');
  403. }
  404. if (button.classList.contains('btn-primary')
  405. || button.getElementsByClassName('count')[0].textContent < MAX_FAVORITES_COUNT_PER_GROUP) {
  406. button.disabled = false;
  407. }
  408. }
  409.  
  410. parent.lastElementChild.addEventListener('click', async function (event) {
  411. const button = event.target.closest('button');
  412. if (!button || button.name !== 'favorite-' + type) {
  413. return;
  414. }
  415.  
  416. const buttons = document.getElementsByName('favorite-' + type);
  417. for (const button of buttons) {
  418. button.disabled = true;
  419. }
  420.  
  421. const id = button.dataset.id;
  422. const newTags = button.classList.contains('btn-secondary') ? [button.value] : [];
  423.  
  424. const favorites = (await getFavorites())[type];
  425. for (let i = favorites.length - 1; i >= 0; i--) {
  426. if (favorites[i].favoriteId === id) {
  427. await fetch(
  428. '/api/1/favorites/' + favorites[i].id,
  429. {method: 'DELETE', credentials: 'same-origin'}
  430. );
  431.  
  432. for (const button of buttons) {
  433. if (favorites[i].tags.includes(button.value)) {
  434. button.classList.remove('btn-primary');
  435. button.classList.add('btn-secondary');
  436. }
  437. }
  438.  
  439. favorites.splice(i, 1);
  440. }
  441. }
  442.  
  443. if (newTags.length > 0) {
  444. await fetch('/api/1/favorites', {
  445. method: 'POST',
  446. headers: { 'content-type': 'application/json' },
  447. credentials: 'same-origin',
  448. body: JSON.stringify({type, favoriteId: id, tags: newTags}),
  449. })
  450. .then(async response => response.ok ? response.json() : Promise.reject(
  451. new Error(`${response.status} ${response.statusText}\n${await response.text()}`)
  452. ))
  453. .then(function (favorite) {
  454. favorites.push(favorite);
  455. for (const button of buttons) {
  456. if (favorite.tags.includes(button.value)) {
  457. button.classList.remove('btn-secondary');
  458. button.classList.add('btn-primary');
  459. }
  460. }
  461. })
  462. .catch(showError);
  463. }
  464.  
  465. await updateFavoriteCounts(type);
  466.  
  467. for (const button of buttons) {
  468. if (button.getElementsByClassName('count')[0].textContent < MAX_FAVORITES_COUNT_PER_GROUP) {
  469. button.disabled = false;
  470. }
  471. }
  472. });
  473. }
  474.  
  475. /**
  476. * フレンド一覧で次のページが確実に存在しない場合、次ページのボタンを無効化します。
  477. * また、オンラインのフレンド数を表示します。
  478. * @param {HTMLDivElement} group 「friend-group」クラスを持つ要素。
  479. * @returns {void}
  480. */
  481. function improveFriendList(group)
  482. {
  483. // フレンド一覧で次のページが確実に存在しない場合、次ページのボタンを無効化します
  484. const pager = group.querySelector('.friend-group > div:last-of-type');
  485. const count = group.querySelectorAll('.friend-group > div:not(:last-of-type)').length;
  486. const nextPageButton = pager.getElementsByClassName('fa-angle-down')[0].closest('button');
  487. nextPageButton.disabled = count < MAX_ITEMS_COUNT;
  488.  
  489. // オンラインのフレンド数を表示します
  490. const heading = group.firstElementChild;
  491. if (heading.textContent.includes('Online')) {
  492. heading.textContent = `Online (${
  493. MAX_ITEMS_COUNT * (/[0-9]+/.exec(pager.getElementsByClassName('page')[0].textContent)[0] - 1)
  494. + count
  495. + (nextPageButton.disabled ? '' : '+')
  496. })`;
  497. }
  498. }
  499.  
  500. /**
  501. * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。
  502. * @type {boolean}
  503. * @access private
  504. */
  505. let headChildrenInserted = false;
  506.  
  507. new MutationObserver(async function (mutations) {
  508. if (document.head && !headChildrenInserted) {
  509. headChildrenInserted = true;
  510. document.head.insertAdjacentHTML('beforeend', `<style>
  511. /*====================================
  512. Edit Profile
  513. */
  514. ` + Object.keys(STATUSES).map(status => `
  515. [name="status"].${CSS.escape(status.replace(' ', '-'))},
  516. [name="status"] option[value=${CSS.escape(status)}] {
  517. color: var(${STATUSES[status].color});
  518. }
  519. `).join('') + `
  520.  
  521. /*====================================
  522. フレンドのユーザーページ
  523. */
  524. .btn[name^="favorite-"] {
  525. white-space: unset;
  526. }
  527. </style>`);
  528.  
  529. // ユーザー情報を取得します
  530. // ページ名を改善します
  531. GreasemonkeyUtils.executeOnUnsafeContext(function (id) {
  532.  
  533. const responseText = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText');
  534. responseText.get = new Proxy(responseText.get, {
  535. apply(get, thisArgument, argumentList)
  536. {
  537. const responseText = Reflect.apply(get, thisArgument, argumentList);
  538. if (thisArgument.status === 200
  539. && new URL(thisArgument.responseURL).pathname === '/api/1/auth/user') {
  540. postMessage({ id, userDetails: JSON.parse(responseText) }, location.origin);
  541. }
  542. return responseText;
  543. },
  544. });
  545. Object.defineProperty(XMLHttpRequest.prototype, 'responseText', responseText);
  546.  
  547. History.prototype.pushState = new Proxy(History.prototype.pushState, {
  548. apply(pushState, thisArgument, argumentList)
  549. {
  550. Reflect.apply(pushState, thisArgument, argumentList);
  551. document.title = document.title.split(' | ').slice(-1)[0];
  552. },
  553. });
  554. }, [ ID ]);
  555.  
  556. addEventListener('popstate', function () {
  557. document.title = document.title.split(' | ').slice(-1)[0];
  558. });
  559. }
  560.  
  561. for (const mutation of mutations) {
  562. let parent = mutation.target;
  563. if (parent.id === 'home') {
  564. break;
  565. }
  566.  
  567. if (/* URLを開いたとき */ parent.localName === 'head' && document.body
  568. || /* ページを移動したとき */ parent.id === 'app' || parent.classList.contains('home-content')
  569. || parent.parentElement && parent.parentElement.classList.contains('home-content')) {
  570. const homeContent = document.getElementsByClassName('home-content')[0];
  571. if (!homeContent || homeContent.getElementsByClassName('fa-cog')[0]) {
  572. break;
  573. }
  574.  
  575. let promise;
  576. if (location.pathname === '/home/profile') {
  577. // 「Edit Profile」ページなら
  578. promise = insertUpdateStatusForm();
  579. } else if (location.pathname.startsWith('/home/user/')) {
  580. // ユーザーページ
  581. promise = insertFavoriteButtons('friend');
  582. if (!document.title.includes('|')) {
  583. const displayName = document.getElementsByTagName('h2')[0].textContent;
  584. const name = document.getElementsByTagName('h3')[0].firstChild.data;
  585. document.title = `${displayName} ${name} | ${document.title}`;
  586. }
  587. } else if (location.pathname.startsWith('/home/world/')) {
  588. // ワールドページ
  589. promise = insertFavoriteButtons('world');
  590. if (!document.title.includes('|')) {
  591. const heading = document.querySelector('.home-content h3');
  592. const name = heading.firstChild.data;
  593. const author = heading.getElementsByTagName('small')[0].textContent;
  594. document.title = `${name} ${author} | ${document.title}`;
  595. }
  596. }
  597. if (promise) {
  598. promise.catch(showError);
  599. }
  600. }
  601.  
  602. if (parent.classList.contains('friend-container')) {
  603. parent = mutation.addedNodes[0];
  604. }
  605.  
  606. if (parent.classList.contains('friend-group')) {
  607. let groups = document.getElementsByClassName('friend-group');
  608. const heading = groups[0].firstElementChild.textContent;
  609. if (groups.length === 1 || heading.includes('Online') && !heading.includes('(')) {
  610. groups = [parent];
  611. }
  612.  
  613. for (const group of groups) {
  614. improveFriendList(group);
  615. }
  616. break;
  617. }
  618. }
  619. }).observe(document, {childList: true, subtree: true});