VRChat Web Pages Extender

Add features into VRChat Web Pages and improve user experience.

目前為 2019-06-02 提交的版本,檢視 最新版本

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