VRChat Web Pages Extender

Add features into VRChat Web Pages and improve user experience.

当前为 2023-01-03 提交的版本,查看 最新版本

  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.13.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. '$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. + ('stack' in exception ? '\n\n' + exception.stack : '');
  62. const homeContent = document.getElementsByClassName('home-content')[0];
  63. if (homeContent) {
  64. homeContent.firstElementChild.firstElementChild.insertAdjacentHTML('afterbegin', h`<div class="row">
  65. <div class="alert alert-danger fade show" role="alert"
  66. style="white-space: pre-wrap; font-size: 1rem; font-weight: normal;">${errorMessage}</div>
  67. </div>`);
  68. } else {
  69. alert(errorMessage); //eslint-disable-line no-alert
  70. }
  71. } catch (e) {
  72. alert(_('エラーが発生しました') + ': ' + e); //eslint-disable-line no-alert
  73. }
  74. }
  75.  
  76. const ID = 'vrchat-web-pages-extender-137';
  77.  
  78. /**
  79. * 一度に取得できる最大の要素数。
  80. * @constant {number}
  81. */
  82. const MAX_ITEMS_COUNT = 100;
  83.  
  84. /**
  85. * Statusの種類。
  86. * @constant {number}
  87. */
  88. const STATUSES = {
  89. 'join me': {
  90. label: 'Join Me: Auto-accept join requests.',
  91. color: '--status-joinme',
  92. },
  93. active: {
  94. label: 'Online: See join requests.',
  95. color: '--status-online',
  96. },
  97. 'ask me': {
  98. label: 'Ask Me: Hide location, see join requests.',
  99. color: '--status-askme',
  100. },
  101. busy: {
  102. label: 'Do Not Disturb: Hide location, hide join requests.',
  103. color: '--status-busy',
  104. },
  105. };
  106.  
  107. /**
  108. * 一つのブックマークグループの最大登録数。
  109. * @constant {Object.<number>}
  110. */
  111. const MAX_FRIEND_FAVORITE_COUNT_PER_GROUP = 150;
  112.  
  113. /**
  114. * @type {Function}
  115. * @access private
  116. */
  117. let resolveUserDetails;
  118.  
  119. /**
  120. * @type {Promise.<Object>}
  121. * @access private
  122. */
  123. const userDetails = new Promise(function (resolve) {
  124. resolveUserDetails = resolve;
  125. });
  126.  
  127. /**
  128. * キーにワールドIDを持つ連想配列。
  129. * @type {Object.<(string|string[]|boolean|number|Object.<(string|number)>[]|(string|number)[][]|null)>}
  130. */
  131. const worlds = { };
  132.  
  133. addEventListener('message', function (event) {
  134. if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null
  135. || event.data.id !== ID) {
  136. return;
  137. }
  138.  
  139. if (event.data.userDetails) {
  140. resolveUserDetails(event.data.userDetails);
  141. } else if (event.data.world) {
  142. worlds[event.data.world.id] = event.data.world;
  143. const locations = document.getElementsByClassName('locations')[0];
  144. if (!locations) {
  145. return;
  146. }
  147. for (const [ instanceId ] of event.data.world.instances) {
  148. const locationLink = locations.querySelector(`.locations
  149. [href*=${CSS.escape(`/home/launch?worldId=${event.data.world.id}&instanceId=${instanceId}`)}]`);
  150. if (!locationLink) {
  151. continue;
  152. }
  153. insertInstanceUserCountAndCapacity(locationLink.closest('.locations > *'), event.data.world.id, instanceId);
  154. }
  155. }
  156. });
  157.  
  158. /**
  159. * ログインしているユーザーの情報を取得します。
  160. * @see [User Info — VRChat API Documentation]{@link https://vrchatapi.github.io/#/UserAPI/CurrentUserDetails}
  161. * @returns {Promise.<?Object.<(string|string[]|boolean|number|Object)>>}
  162. */
  163. async function getUserDetails()
  164. {
  165. return await userDetails;
  166. }
  167.  
  168. /**
  169. * JSONファイルをオブジェクトとして取得します。
  170. * @param {string} url
  171. * @returns {Promise.<(Object|Array)>} OKステータスでなければ失敗します。
  172. */
  173. async function fetchJSON(url)
  174. {
  175. const response = await fetch(url, {credentials: 'same-origin'});
  176. return response.ok
  177. ? response.json()
  178. : Promise.reject(new Error(`${response.status} ${response.statusText}\n${await response.text()}`));
  179. }
  180.  
  181. let friendFavoriteGroupNameDisplayNamePairs;
  182.  
  183. /**
  184. * ログインしているユーザーのフレンドfavoriteのグループ名を取得します。
  185. * @returns {Promise.<Object.<string>[]>}
  186. */
  187. function getFriendFavoriteGroupNameDisplayNamePairs()
  188. {
  189. if (!friendFavoriteGroupNameDisplayNamePairs) {
  190. friendFavoriteGroupNameDisplayNamePairs
  191. = fetchJSON('/api/1/favorite/groups?type=friend', {credentials: 'same-origin'}).then(function (groups) {
  192. const groupNameDisplayNamePairs = {};
  193. for (const group of groups) {
  194. groupNameDisplayNamePairs[group.name] = group.displayName;
  195. }
  196. return groupNameDisplayNamePairs;
  197. });
  198. }
  199. return friendFavoriteGroupNameDisplayNamePairs;
  200. }
  201.  
  202. /**
  203. * @type {Promise.<Object.<(string|string[])>[]>}
  204. * @access private
  205. */
  206. let friendFavoritesPromise;
  207.  
  208. /**
  209. * ブックマークを全件取得します。
  210. * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites}
  211. * @returns {Promise.<Object.<(string|string[])>[]>}
  212. */
  213. function getFriendFavorites()
  214. {
  215. return friendFavoritesPromise || (friendFavoritesPromise = async function () {
  216. const allFavorites = [];
  217. let offset = 0;
  218.  
  219. while (true) { //eslint-disable-line no-constant-condition
  220. const favorites = await fetchJSON(
  221. `/api/1/favorites/?type=friend&n=${MAX_ITEMS_COUNT}&offset=${offset}`,
  222. ).catch(showError);
  223.  
  224. allFavorites.push(...favorites);
  225.  
  226. if (favorites.length < MAX_ITEMS_COUNT) {
  227. break;
  228. }
  229.  
  230. offset += favorites.length;
  231. }
  232. return allFavorites;
  233. }());
  234. }
  235.  
  236. /**
  237. * 自分のユーザーページに、ステータス変更コントロールを挿入します。
  238. * @returns {void}
  239. */
  240. function insertUpdateStatusControl()
  241. {
  242. if (document.getElementsByName('update-status')[0]) {
  243. // すでに挿入済みなら
  244. return;
  245. }
  246.  
  247. const editStatusDescriptionControl = document.querySelector('[role="button"][title="Edit Status"]');
  248. if (!editStatusDescriptionControl) {
  249. return;
  250. }
  251.  
  252. editStatusDescriptionControl.insertAdjacentHTML('beforebegin', `<select name="update-status">
  253. <option value="">Select Status</option>
  254. ${Object.keys(STATUSES)
  255. .map(status => h`<option value="${status}">⬤ ${STATUSES[status].label}</option>`).join('')}
  256. </select>`);
  257. editStatusDescriptionControl.previousElementSibling.addEventListener('change', async function (event) {
  258. if (event.target.value === '') {
  259. return;
  260. }
  261.  
  262. event.target.disabled = true;
  263.  
  264. try {
  265. const response = await fetch(location.pathname.replace('/home/user/', '/api/1/users/'), {
  266. method: 'PUT',
  267. headers: {'content-type': 'application/json'},
  268. credentials: 'same-origin',
  269. body: JSON.stringify({ status: event.target.value }),
  270. });
  271. if (!response.ok) {
  272. return Promise.reject(
  273. new Error(`${response.status} ${response.statusText}\n${await response.text()}`),
  274. );
  275. }
  276. } catch (exception) {
  277. showError(exception);
  278. } finally {
  279. event.target.value = '';
  280. event.target.disabled = false;
  281. }
  282. });
  283. }
  284.  
  285. /**
  286. * フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。
  287. * @returns {Promise.<void>}
  288. */
  289. async function updateFriendFavoriteCounts()
  290. {
  291. const counts = {};
  292. for (const favorite of await getFriendFavorites()) {
  293. for (const tag of favorite.tags) {
  294. if (!(tag in counts)) {
  295. counts[tag] = 0;
  296. }
  297. counts[tag]++;
  298. }
  299. }
  300.  
  301. for (const button of document.getElementsByName('favorite-friend')) {
  302. button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0;
  303. }
  304. }
  305.  
  306. /**
  307. * ユーザーページへブックマーク登録/解除ボタンを追加します。
  308. * @returns {Promise.<void>}
  309. */
  310. async function insertFriendFavoriteButtons()
  311. {
  312. const homeContent = document.getElementsByClassName('home-content')[0];
  313. const sibling = homeContent.querySelector('[role="group"]');
  314. if (!sibling) {
  315. return;
  316. }
  317.  
  318. 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);
  319. if (!result) {
  320. return;
  321. }
  322. const id = result[0];
  323.  
  324. if (!(await getUserDetails()).friends.includes(id)) {
  325. return;
  326. }
  327.  
  328. const buttons = document.getElementsByName('favorite-friend');
  329. const groupNameDisplayNamePairs = await getFriendFavoriteGroupNameDisplayNamePairs();
  330. const groupNames = Object.keys(groupNameDisplayNamePairs);
  331. const buttonsParent = buttons[0] && buttons[0].closest('[role="group"]');
  332. if (buttonsParent) {
  333. // 多重挿入の防止
  334. if (buttonsParent.dataset.id === id) {
  335. return;
  336. } else {
  337. buttonsParent.remove();
  338. }
  339. }
  340. sibling.insertAdjacentHTML('afterend', `<div role="group" class="w-100 btn-group-lg btn-group-vertical mt-4"
  341. data-id="${h(id)}">
  342. ${groupNames.sort().map(tag => h`<button type="button"
  343. class="btn btn-secondary" name="favorite-friend" value="${tag}" disabled="">
  344. <span aria-hidden="true" class="fa fa-star"></span>
  345. &#xA0;<span class="name">${groupNameDisplayNamePairs[tag]}</span>
  346. &#xA0;<span class="count">‒</span>⁄${MAX_FRIEND_FAVORITE_COUNT_PER_GROUP}
  347. </button>`).join('')}
  348. </div>`);
  349.  
  350. await updateFriendFavoriteCounts();
  351.  
  352. const tags = [].concat(
  353. ...(await getFriendFavorites()).filter(favorite => favorite.favoriteId === id).map(favorite => favorite.tags),
  354. );
  355.  
  356. for (const button of buttons) {
  357. button.dataset.id = id;
  358. if (tags.includes(button.value)) {
  359. button.classList.remove('btn-secondary');
  360. button.classList.add('btn-primary');
  361. }
  362. if (button.classList.contains('btn-primary')
  363. || button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
  364. button.disabled = false;
  365. }
  366. }
  367.  
  368. buttons[0].closest('[role="group"]').addEventListener('click', async function (event) {
  369. const button = event.target.closest('button');
  370. if (!button || button.name !== 'favorite-friend') {
  371. return;
  372. }
  373.  
  374. const buttons = document.getElementsByName('favorite-friend');
  375. for (const button of buttons) {
  376. button.disabled = true;
  377. }
  378.  
  379. const id = button.dataset.id;
  380. const newTags = button.classList.contains('btn-secondary') ? [button.value] : [];
  381.  
  382. const favorites = await getFriendFavorites();
  383. for (let i = favorites.length - 1; i >= 0; i--) {
  384. if (favorites[i].favoriteId === id) {
  385. await fetch(
  386. '/api/1/favorites/' + favorites[i].id,
  387. {method: 'DELETE', credentials: 'same-origin'},
  388. );
  389.  
  390. for (const button of buttons) {
  391. if (favorites[i].tags.includes(button.value)) {
  392. button.classList.remove('btn-primary');
  393. button.classList.add('btn-secondary');
  394. }
  395. }
  396.  
  397. favorites.splice(i, 1);
  398. }
  399. }
  400.  
  401. if (newTags.length > 0) {
  402. await fetch('/api/1/favorites', {
  403. method: 'POST',
  404. headers: { 'content-type': 'application/json' },
  405. credentials: 'same-origin',
  406. body: JSON.stringify({type: 'friend', favoriteId: id, tags: newTags}),
  407. })
  408. .then(async response => response.ok ? response.json() : Promise.reject(
  409. new Error(`${response.status} ${response.statusText}\n${await response.text()}`),
  410. ))
  411. .then(function (favorite) {
  412. favorites.push(favorite);
  413. for (const button of buttons) {
  414. if (favorite.tags.includes(button.value)) {
  415. button.classList.remove('btn-secondary');
  416. button.classList.add('btn-primary');
  417. }
  418. }
  419. })
  420. .catch(showError);
  421. }
  422.  
  423. await updateFriendFavoriteCounts();
  424.  
  425. for (const button of buttons) {
  426. if (button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
  427. button.disabled = false;
  428. }
  429. }
  430. });
  431. }
  432.  
  433. /**
  434. * Friend Locationsページのインスタンスへ、現在のインスタンス人数と上限を表示します。
  435. * @param {HTMLDivElement} location
  436. * @returns {void}
  437. */
  438. function insertInstanceUserCountAndCapacity(location, worldId, instanceId)
  439. {
  440. const world = worlds[worldId];
  441. const instanceUserCount = world?.instances?.find(([ id ]) => id === instanceId)?.[1];
  442.  
  443. /** @type {HTMLElement} */
  444. let counts = location.getElementsByClassName('instance-user-count-and-capacity')[0];
  445. if (!counts) {
  446. const friendCount = location.querySelector('[aria-label="Invite Me"]').parentElement.previousElementSibling;
  447. counts = friendCount.cloneNode();
  448. counts.classList.add('instance-user-count-and-capacity');
  449. counts.style.whiteSpace = 'nowrap';
  450. friendCount.before(counts);
  451. }
  452. counts.textContent = (instanceUserCount ?? '?') + ' / ' + (world?.capacity ?? '?');
  453. }
  454.  
  455. /**
  456. * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。
  457. * @type {boolean}
  458. * @access private
  459. */
  460. let headChildrenInserted = false;
  461.  
  462. const homeContents = document.getElementsByClassName('home-content');
  463.  
  464. new MutationObserver(function (mutations, observer) {
  465. if (document.head && !headChildrenInserted) {
  466. headChildrenInserted = true;
  467. document.head.insertAdjacentHTML('beforeend', `<style>
  468. /*====================================
  469. Edit Profile
  470. */
  471. ` + Object.keys(STATUSES).map(status => `
  472. [name="update-status"].${CSS.escape(status.replace(' ', '-'))},
  473. [name="update-status"] option[value=${CSS.escape(status)}] {
  474. color: var(${STATUSES[status].color});
  475. }
  476. `).join('') + `
  477.  
  478. /*====================================
  479. フレンドのユーザーページ
  480. */
  481. .btn[name^="favorite-"] {
  482. white-space: unset;
  483. }
  484. </style>`);
  485.  
  486. // ユーザー情報・ワールド情報を取得
  487. GreasemonkeyUtils.executeOnUnsafeContext(function (id) {
  488. Response.prototype.text = new Proxy(Response.prototype.text, {
  489. apply(get, thisArgument, argumentList)
  490. {
  491. const textPromise = Reflect.apply(get, thisArgument, argumentList);
  492. (async function () {
  493. const data = { id };
  494. const pathname = new URL(thisArgument.url).pathname;
  495. if (pathname === '/api/1/auth/user') {
  496. data.userDetails = JSON.parse(await textPromise);
  497. } else if (pathname.startsWith('/api/1/worlds/wrld_')) {
  498. data.world = JSON.parse(await textPromise);
  499. }
  500. postMessage(data, location.origin);
  501. })();
  502. return textPromise;
  503. },
  504. });
  505. }, [ ID ]);
  506. }
  507.  
  508. if (!homeContents[0]) {
  509. return;
  510. }
  511.  
  512. const locationsList = homeContents[0].getElementsByClassName('locations');
  513. const instanceUserCountAndCapacityList = homeContents[0].getElementsByClassName('instance-user-count-and-capacity');
  514.  
  515. new MutationObserver(async function (mutations) {
  516. for (const mutation of mutations) {
  517. if (locationsList[0]) {
  518. if (locationsList[0].children.length !== instanceUserCountAndCapacityList.length) {
  519. // Friend Locationsへインスタンス人数を追加
  520. for (const location of locationsList[0].children) {
  521. if (location.getElementsByClassName('instance-user-count-and-capacity')[0]) {
  522. continue;
  523. }
  524.  
  525. const launchLink = location.querySelector('[href*="/home/launch?"]');
  526. if (!launchLink) {
  527. continue;
  528. }
  529. const params = new URLSearchParams(launchLink.search);
  530. insertInstanceUserCountAndCapacity(location, params.get('worldId'), params.get('instanceId'));
  531. }
  532. }
  533. } else if (mutation.addedNodes.length > 0 && mutation.target.nodeType === Node.ELEMENT_NODE
  534. && (/* ユーザーページを開いたとき */ mutation.target.classList.contains('home-content')
  535. || /* ワールドページを開いたとき */ mutation.target.parentElement.classList.contains('home-content'))
  536. || /* ユーザーページ間を移動したとき */ mutation.type === 'characterData'
  537. && mutation.target.parentElement.matches('.subheader *')) {
  538. if (location.pathname.startsWith('/home/user/')) {
  539. // ユーザーページ
  540. insertUpdateStatusControl();
  541. await insertFriendFavoriteButtons('friend');
  542. } else if (location.pathname.startsWith('/home/world/')) {
  543. // ワールドページ
  544. const heading = document.querySelector('.home-content h2');
  545. const name = heading.firstChild.data;
  546. const author
  547. = heading.nextElementSibling.querySelector('[href^="/home/user/usr_"]').firstChild.data;
  548. document.title = `${name} By ${author} - VRChat`;
  549. } else if (location.pathname.startsWith('/home/avatar/')) {
  550. // アバターページ
  551. const name = document.querySelector('.home-content h3').textContent;
  552. const author = document.querySelector('.home-content [href^="/home/user/usr_"]').text;
  553. document.title = `${name} By ${author} - VRChat`;
  554. } else if (location.pathname.startsWith('/home/group/')) {
  555. // グループページ
  556. const name = document.querySelector('.home-content h2').textContent;
  557. const shortCodeAndDiscriminator
  558. = document.querySelector('[href^="https://vrc.group/"]').textContent;
  559. document.title = `${name} ${shortCodeAndDiscriminator} - VRChat`;
  560. }
  561. break;
  562. } else if (mutation.target.title === 'Edit Status' && mutation.target.getAttribute('role') === 'button') {
  563. // 自分のユーザーページのStatus Description入力欄へ履歴を追加
  564. if (mutation.addedNodes[0]?.placeholder === 'Set a new status!') {
  565. mutation.target.insertAdjacentHTML('beforeend', `<datalist id="status-description-history">
  566. ${(await getUserDetails())
  567. .statusHistory.map(statusDescription => h`<option>${statusDescription}</option>`).join('')}
  568. </datalist>`);
  569. mutation.addedNodes[0].setAttribute('list', 'status-description-history');
  570. } else if (mutation.removedNodes[0]?.placeholder === 'Set a new status!') {
  571. document.getElementById('status-description-history')?.remove();
  572. }
  573. }
  574. }
  575. }).observe(homeContents[0], {childList: true, characterData: true, subtree: true });
  576.  
  577. observer.disconnect();
  578. }).observe(document, {childList: true, subtree: true});