- // ==UserScript==
- // @name VRChat Web Pages Extender
- // @name:ja VRChat Webページ拡張
- // @description Add features into VRChat Web Pages and improve user experience.
- // @description:ja VRChatのWebページに機能を追加し、また使い勝手を改善します。
- // @namespace https://greasyfork.org/users/137
- // @version 2.7.0
- // @match https://www.vrchat.com/*
- // @match https://vrchat.com/*
- // @match https://api.vrchat.cloud/*
- // @require https://greasyfork.org/scripts/19616/code/utilities.js?version=895049
- // @license MPL-2.0
- // @contributionURL https://pokemori.booth.pm/items/969835
- // @compatible Edge
- // @compatible Firefox Firefoxを推奨 / Firefox is recommended
- // @compatible Opera
- // @compatible Chrome
- // @grant dummy
- // @run-at document-start
- // @icon https://images.squarespace-cdn.com/content/v1/5f0770791aaf57311515b23d/1599678606410-4QMTB25DHF87E8EFFKXY/ke17ZwdGBToddI8pDm48kGfiFqkITS6axXxhYYUCnlRZw-zPPgdn4jUwVcJE1ZvWQUxwkmyExglNqGp0IvTJZUJFbgE-7XRK3dMEBRBhUpxQ1ibo-zdhORxWnJtmNCajDe36aQmu-4Z4SFOss0oowgxUaachD66r8Ra2gwuBSqM/favicon.ico
- // @author 100の人
- // @homepageURL https://pokemori.booth.pm/items/969835
- // ==/UserScript==
-
- 'use strict';
-
- // L10N
- Gettext.setLocalizedTexts({
- /*eslint-disable quote-props, max-len */
- 'en': {
- 'エラーが発生しました': 'Error occurred',
- '$LENGTH$ 文字まで表示可能です。': 'This text is displayed up to $LENGTH$ characters.',
- },
- /*eslint-enable quote-props, max-len */
- });
-
- Gettext.setLocale(navigator.language);
-
-
-
- if (typeof content !== 'undefined') {
- // For Greasemonkey 4
- fetch = content.fetch.bind(content); //eslint-disable-line no-global-assign, no-native-reassign, no-undef
- }
-
-
-
- /**
- * ページ上部にエラー内容を表示します。
- * @param {Error} exception
- * @returns {void}
- */
- function showError(exception)
- {
- console.error(exception);
- try {
- const errorMessage = _('エラーが発生しました') + ': ' + exception
- + ('stack' in exception ? '\n\n' + exception.stack : '');
- const homeContent = document.getElementsByClassName('home-content')[0];
- if (homeContent) {
- homeContent.firstElementChild.firstElementChild.insertAdjacentHTML('afterbegin', h`<div class="row">
- <div class="alert alert-danger fade show" role="alert"
- style="white-space: pre-wrap; font-size: 1rem; font-weight: normal;">${errorMessage}</div>
- </div>`);
- } else {
- alert(errorMessage);
- }
- } catch (e) {
- alert(_('エラーが発生しました') + ': ' + e);
- }
- }
-
- const ID = 'vrchat-web-pages-extender-137';
-
- /**
- * 一度に取得できる最大の要素数。
- * @constant {number}
- */
- const MAX_ITEMS_COUNT = 100;
-
- /**
- * Statusの種類。
- * @constant {number}
- */
- const STATUSES = {
- 'join me': {
- label: 'Join Me: Auto-accept join requests.',
- color: '--status-joinme',
- },
- active: {
- label: 'Online: See join requests.',
- color: '--status-online',
- },
- 'ask me': {
- label: 'Ask Me: Hide location, see join requests.',
- color: '--status-askme',
- },
- busy: {
- label: 'Do Not Disturb: Hide location, hide join requests.',
- color: '--status-busy',
- },
- };
-
- /**
- * Status Descriptionの最大文字数。
- * @constant {number}
- */
- const MAX_STATUS_DESCRIPTION_LENGTH = 32;
-
- /**
- * 一つのブックマークグループの最大登録数。
- * @constant {number}
- */
- const MAX_FAVORITES_COUNT_PER_GROUP = 32;
-
- /**
- * @type {Function}
- * @access private
- */
- let resolveUserDetails;
-
- /**
- * @type {Promise.<Object>}
- * @access private
- */
- const userDetails = new Promise(function (resolve) {
- resolveUserDetails = resolve;
- });
-
- addEventListener('message', function receiveUserDetails(event) {
- if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null
- || event.data.id !== ID || !event.data.userDetails) {
- return;
- }
-
- event.currentTarget.removeEventListener(event.type, receiveUserDetails);
-
- resolveUserDetails(event.data.userDetails);
- });
-
- /**
- * ログインしているユーザーの情報を取得します。
- * @see [User Info — VRChat API Documentation]{@link https://vrchatapi.github.io/#/UserAPI/CurrentUserDetails}
- * @returns {Promise.<?Object.<(string|string[]|boolean|number|Object)>>}
- */
- async function getUserDetails()
- {
- return await userDetails;
- }
-
- /**
- * JSONファイルをオブジェクトとして取得します。
- * @param {string} url
- * @returns {Promise.<(Object|Array)>} OKステータスでなければ失敗します。
- */
- async function fetchJSON(url)
- {
- const response = await fetch(url, {credentials: 'same-origin'});
- return response.ok
- ? response.json()
- : Promise.reject(new Error(`${response.status} ${response.statusText}\n${await response.text()}`));
- }
-
- /**
- * スクリプトで扱うブックマークの種類。
- * @constant {string[]}
- */
- const FAVORITE_TYPES = ['friend', 'world'];
-
- let favoriteTypeGroupsPairsPromise;
-
- /**
- * ログインしているユーザーのfavoriteのグループ名を取得します。
- * @returns {Object.<Object.<string>[]>} {@link FAVORITE_TYPES}の要素をキーに持つ連想配列。
- */
- function getFavoriteTypeGroupsPairs()
- {
- if (!favoriteTypeGroupsPairsPromise) {
- favoriteTypeGroupsPairsPromise = fetchJSON('/api/1/favorite/groups', {credentials: 'same-origin'})
- .then(function (groups) {
- const favoriteTypeGroupsPairs = {};
- for (const group of groups.filter(group => FAVORITE_TYPES.includes(group.type))) {
- if (!(group.type in favoriteTypeGroupsPairs)) {
- favoriteTypeGroupsPairs[group.type] = {};
- }
- favoriteTypeGroupsPairs[group.type][group.name] = group.displayName;
- }
- return favoriteTypeGroupsPairs;
- });
- }
- return favoriteTypeGroupsPairsPromise;
- }
-
- /**
- * @type {Promise.<Object.<(string|string[])[]>[]>}
- * @access private
- */
- let favorites;
-
- /**
- * ブックマークを全件取得します。
- * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites}
- * @returns {Promise.<Object.<(string|string[])[]>[]>} {@link FAVORITE_TYPES}の要素をキーに持つ連想配列。
- */
- function getFavorites()
- {
- return favorites || (favorites = async function () {
- const allFavorites = { };
- for (const type of FAVORITE_TYPES) {
- allFavorites[type] = [];
- }
- let offset = 0;
- while (true) {
- const favorites
- = await fetchJSON(`/api/1/favorites/?n=${MAX_ITEMS_COUNT}&offset=${offset}`).catch(showError);
-
- for (const favorite of favorites) {
- if (!FAVORITE_TYPES.includes(favorite.type)) {
- continue;
- }
- allFavorites[favorite.type].push(favorite);
- }
-
- if (favorites.length < MAX_ITEMS_COUNT) {
- break;
- }
-
- offset += favorites.length;
- }
- return allFavorites;
- }());
- }
-
- /**
- * 「Edit Profile」ページに、ステータス文変更フォームを挿入します。
- * @returns {Promise.<void>}
- */
- async function insertUpdateStatusForm()
- {
- if ('update-status' in document.forms) {
- return;
- }
-
- const sidebarStatus = document.querySelector('.leftbar .user-info h6 span[title]');
- const sidebarStatusDescription = document.querySelector('.leftbar .statusDescription small');
-
- const templateCard = document.getElementById('name-change-submit').closest('.card');
- const card = templateCard.cloneNode(true);
- card.getElementsByClassName('card-header')[0].textContent = 'Status';
- const form = card.getElementsByTagName('form')[0];
- form.name = 'update-status';
- form.action = '/api/1/users/' + document.querySelector('[href*="/home/user/usr_"]').pathname.replace(/.+\//u, '');
-
- const description = form.displayName;
- description.id = 'status-description';
- description.type = 'text';
- description.name = 'statusDescription';
- description.value = sidebarStatusDescription.textContent;
- description.placeholder = '';
- description.pattern = `.{0,${MAX_STATUS_DESCRIPTION_LENGTH}}`;
- description.title = _('$LENGTH$ 文字まで表示可能です。').replace('$LENGTH$', MAX_STATUS_DESCRIPTION_LENGTH);
- description.parentElement.getElementsByClassName('alert')[0].remove();
- const descriptionContainer = description.closest('.col-10');
- descriptionContainer.classList.replace('col-10', 'col');
-
- descriptionContainer.parentElement.classList.add('mb-2');
-
- const statusContainer = descriptionContainer.previousElementSibling;
- statusContainer.outerHTML = '<div class="col-auto"><select name="status" class="form-control">'
- + Object.keys(STATUSES).map(status => h`<option value="${status}">⬤ ${STATUSES[status].label}</option>`)
- .join('')
- + '</select></div>';
- form.status.value = sidebarStatus.title;
- form.status.classList.add(form.status.value.replace(' ', '-'));
- form.status.addEventListener('change', function (event) {
- const classList = event.target.classList;
- classList.remove(...Object.keys(STATUSES).map(status => status.replace(' ', '-')));
- classList.add(event.target.value.replace(' ', '-'));
- });
-
- const submit = form.getElementsByClassName('btn')[0];
- submit.id = 'status-change-submit';
- submit.textContent = 'Update Status';
- submit.disabled = false;
-
- form.addEventListener('submit', function (event) {
- event.preventDefault();
- for (const control of event.target) {
- control.disabled = true;
- }
-
- const body = {};
- for (const element of event.target) {
- if (element.localName === 'button') {
- continue;
- }
- body[element.name] = element.value;
- }
-
- fetch(event.target.action, {
- method: 'PUT',
- headers: {'content-type': 'application/json'},
- credentials: 'same-origin',
- body: JSON.stringify(body),
- })
- .then(async function (response) {
- if (!response.ok) {
- return Promise.reject(
- new Error(`${response.status} ${response.statusText}\n${await response.text()}`)
- );
- }
- sidebarStatus.classList.remove(...Object.keys(STATUSES).map(status => status.replace(' ', '-')));
- sidebarStatus.classList.add(event.target.status.value.replace(' ', '-'));
- sidebarStatus.title = event.target.status;
- sidebarStatusDescription.textContent = event.target.statusDescription.value;
- })
- .catch(showError)
- .then(function () {
- for (const control of event.target) {
- control.disabled = false;
- }
- });
- });
-
- templateCard.parentElement.getElementsByClassName('card')[0].before(card, document.createElement('hr'));
- }
-
- /**
- * ブックマーク登録/解除ボタンの登録数表示を更新します。
- * @param {string} type - 「user」「favorite」のいずれか。
- * @returns {Promise.<void>}
- */
- async function updateFavoriteCounts(type)
- {
- const counts = {};
- for (const favorite of (await getFavorites())[type]) {
- for (const tag of favorite.tags) {
- if (!(tag in counts)) {
- counts[tag] = 0;
- }
- counts[tag]++;
- }
- }
-
- for (const button of document.getElementsByName('favorite-' + type)) {
- button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0;
- }
- }
-
- /**
- * ブックマーク登録/解除ボタンを追加します。
- * @param {string} type - {@link FAVOLITE_TYPES}のいずれかの要素。
- * @returns {Promise.<void>}
- */
- async function insertFavoriteButtons(type)
- {
- const homeContent = document.getElementsByClassName('home-content')[0];
- if (!homeContent.querySelector('[name="type"][value="public"]')) {
- // privateワールド
- return;
- }
-
- const sibling = homeContent.querySelector('[role="group"]');
- if (!sibling) {
- return;
- }
- const parent = sibling.closest('[class*="col-"]');
-
- 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);
- if (!result) {
- return;
- }
- const id = result[0];
-
- const buttons = document.getElementsByName('favorite-' + type);
- if (type === 'friend' && !(await getUserDetails()).friends.includes(id) || buttons[0]) {
- return;
- }
-
- const groupNameDisplayNamePairs = (await getFavoriteTypeGroupsPairs())[type];
- const groupNames = Object.keys(groupNameDisplayNamePairs);
- if (parent.querySelector(`[name="favorite-${CSS.escape(type)}"][value="${CSS.escape(groupNames[0])}"]`)) {
- // 多重挿入の防止
- return;
- }
- parent.insertAdjacentHTML('beforeend', '<div role="group" class="w-100 btn-group-lg btn-group-vertical mt-4">'
- + groupNames.sort().map(tag => h`<button type="button"
- class="btn btn-secondary" name="favorite-${type}" value="${tag}" disabled="">
- <span aria-hidden="true" class="fa fa-star"></span>
-  <span class="name">${groupNameDisplayNamePairs[tag]}</span>
-  <span class="count">‒</span>⁄${MAX_FAVORITES_COUNT_PER_GROUP}
- </button>`).join('')
- + '</div>');
-
- await updateFavoriteCounts(type);
-
- const tags = [].concat(...(await getFavorites())[type]
- .filter(favorite => favorite.favoriteId === id)
- .map(favorite => favorite.tags));
-
- for (const button of buttons) {
- button.dataset.id = id;
- if (tags.includes(button.value)) {
- button.classList.remove('btn-secondary');
- button.classList.add('btn-primary');
- }
- if (button.classList.contains('btn-primary')
- || button.getElementsByClassName('count')[0].textContent < MAX_FAVORITES_COUNT_PER_GROUP) {
- button.disabled = false;
- }
- }
-
- parent.lastElementChild.addEventListener('click', async function (event) {
- const button = event.target.closest('button');
- if (!button || button.name !== 'favorite-' + type) {
- return;
- }
-
- const buttons = document.getElementsByName('favorite-' + type);
- for (const button of buttons) {
- button.disabled = true;
- }
-
- const id = button.dataset.id;
- const newTags = button.classList.contains('btn-secondary') ? [button.value] : [];
-
- const favorites = (await getFavorites())[type];
- for (let i = favorites.length - 1; i >= 0; i--) {
- if (favorites[i].favoriteId === id) {
- await fetch(
- '/api/1/favorites/' + favorites[i].id,
- {method: 'DELETE', credentials: 'same-origin'}
- );
-
- for (const button of buttons) {
- if (favorites[i].tags.includes(button.value)) {
- button.classList.remove('btn-primary');
- button.classList.add('btn-secondary');
- }
- }
-
- favorites.splice(i, 1);
- }
- }
-
- if (newTags.length > 0) {
- await fetch('/api/1/favorites', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- credentials: 'same-origin',
- body: JSON.stringify({type, favoriteId: id, tags: newTags}),
- })
- .then(async response => response.ok ? response.json() : Promise.reject(
- new Error(`${response.status} ${response.statusText}\n${await response.text()}`)
- ))
- .then(function (favorite) {
- favorites.push(favorite);
- for (const button of buttons) {
- if (favorite.tags.includes(button.value)) {
- button.classList.remove('btn-secondary');
- button.classList.add('btn-primary');
- }
- }
- })
- .catch(showError);
- }
-
- await updateFavoriteCounts(type);
-
- for (const button of buttons) {
- if (button.getElementsByClassName('count')[0].textContent < MAX_FAVORITES_COUNT_PER_GROUP) {
- button.disabled = false;
- }
- }
- });
- }
-
- /**
- * フレンド一覧で次のページが確実に存在しない場合、次ページのボタンを無効化します。
- * また、オンラインのフレンド数を表示します。
- * @param {HTMLDivElement} group 「friend-group」クラスを持つ要素。
- * @returns {void}
- */
- function improveFriendList(group)
- {
- // フレンド一覧で次のページが確実に存在しない場合、次ページのボタンを無効化します
- const pager = group.querySelector('.friend-group > div:last-of-type');
- const count = group.querySelectorAll('.friend-group > div:not(:last-of-type)').length;
- const nextPageButton = pager.getElementsByClassName('fa-angle-down')[0].closest('button');
- nextPageButton.disabled = count < MAX_ITEMS_COUNT;
-
- // オンラインのフレンド数を表示します
- const heading = group.firstElementChild;
- if (heading.textContent.includes('Online')) {
- heading.textContent = `Online (${
- MAX_ITEMS_COUNT * (/[0-9]+/.exec(pager.getElementsByClassName('page')[0].textContent)[0] - 1)
- + count
- + (nextPageButton.disabled ? '' : '+')
- })`;
- }
- }
-
- /**
- * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。
- * @type {boolean}
- * @access private
- */
- let headChildrenInserted = false;
-
- new MutationObserver(async function (mutations) {
- if (document.head && !headChildrenInserted) {
- headChildrenInserted = true;
- document.head.insertAdjacentHTML('beforeend', `<style>
- /*====================================
- Edit Profile
- */
- ` + Object.keys(STATUSES).map(status => `
- [name="status"].${CSS.escape(status.replace(' ', '-'))},
- [name="status"] option[value=${CSS.escape(status)}] {
- color: var(${STATUSES[status].color});
- }
- `).join('') + `
-
- /*====================================
- フレンドのユーザーページ
- */
- .btn[name^="favorite-"] {
- white-space: unset;
- }
- </style>`);
-
- // ユーザー情報を取得します
- // ページ名を改善します
- GreasemonkeyUtils.executeOnUnsafeContext(function (id) {
-
- const responseText = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText');
- responseText.get = new Proxy(responseText.get, {
- apply(get, thisArgument, argumentList)
- {
- const responseText = Reflect.apply(get, thisArgument, argumentList);
- if (thisArgument.status === 200
- && new URL(thisArgument.responseURL).pathname === '/api/1/auth/user') {
- postMessage({ id, userDetails: JSON.parse(responseText) }, location.origin);
- }
- return responseText;
- },
- });
- Object.defineProperty(XMLHttpRequest.prototype, 'responseText', responseText);
-
- History.prototype.pushState = new Proxy(History.prototype.pushState, {
- apply(pushState, thisArgument, argumentList)
- {
- Reflect.apply(pushState, thisArgument, argumentList);
- document.title = document.title.split(' | ').slice(-1)[0];
- },
- });
- }, [ ID ]);
-
- addEventListener('popstate', function () {
- document.title = document.title.split(' | ').slice(-1)[0];
- });
- }
-
- for (const mutation of mutations) {
- let parent = mutation.target;
- if (parent.id === 'home') {
- break;
- }
-
- if (/* URLを開いたとき */ parent.localName === 'head' && document.body
- || /* ページを移動したとき */ parent.id === 'app' || parent.classList.contains('home-content')
- || parent.parentElement && parent.parentElement.classList.contains('home-content')) {
- const homeContent = document.getElementsByClassName('home-content')[0];
- if (!homeContent || homeContent.getElementsByClassName('fa-cog')[0]) {
- break;
- }
-
- let promise;
- if (location.pathname === '/home/profile') {
- // 「Edit Profile」ページなら
- promise = insertUpdateStatusForm();
- } else if (location.pathname.startsWith('/home/user/')) {
- // ユーザーページ
- promise = insertFavoriteButtons('friend');
- if (!document.title.includes('|')) {
- const displayName = document.getElementsByTagName('h2')[0].textContent;
- const name = document.getElementsByTagName('h3')[0].firstChild.data;
- document.title = `${displayName} — ${name} | ${document.title}`;
- }
- } else if (location.pathname.startsWith('/home/world/')) {
- // ワールドページ
- promise = insertFavoriteButtons('world');
- if (!document.title.includes('|')) {
- const heading = document.querySelector('.home-content h3');
- const name = heading.firstChild.data;
- const author = heading.getElementsByTagName('small')[0].textContent;
- document.title = `${name} ${author} | ${document.title}`;
- }
- }
- if (promise) {
- promise.catch(showError);
- }
- }
-
- if (parent.classList.contains('friend-container')) {
- parent = mutation.addedNodes[0];
- }
-
- if (parent.classList.contains('friend-group')) {
- let groups = document.getElementsByClassName('friend-group');
- const heading = groups[0].firstElementChild.textContent;
- if (groups.length === 1 || heading.includes('Online') && !heading.includes('(')) {
- groups = [parent];
- }
-
- for (const group of groups) {
- improveFriendList(group);
- }
- break;
- }
- }
- }).observe(document, {childList: true, subtree: true});