您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add features into VRChat Web Pages and improve user experience.
当前为
- // ==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.13.0
- // @match https://vrchat.com/home
- // @match https://vrchat.com/home?*
- // @match https://vrchat.com/home#*
- // @match https://vrchat.com/home/*
- // @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://greasyfork.org/scripts/371331
- // ==/UserScript==
- /*global Gettext, _, h, GreasemonkeyUtils */
- '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); //eslint-disable-line no-alert
- }
- } catch (e) {
- alert(_('エラーが発生しました') + ': ' + e); //eslint-disable-line no-alert
- }
- }
- 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',
- },
- };
- /**
- * 一つのブックマークグループの最大登録数。
- * @constant {Object.<number>}
- */
- const MAX_FRIEND_FAVORITE_COUNT_PER_GROUP = 150;
- /**
- * @type {Function}
- * @access private
- */
- let resolveUserDetails;
- /**
- * @type {Promise.<Object>}
- * @access private
- */
- const userDetails = new Promise(function (resolve) {
- resolveUserDetails = resolve;
- });
- /**
- * キーにワールドIDを持つ連想配列。
- * @type {Object.<(string|string[]|boolean|number|Object.<(string|number)>[]|(string|number)[][]|null)>}
- */
- const worlds = { };
- addEventListener('message', function (event) {
- if (event.origin !== location.origin || typeof event.data !== 'object' || event.data === null
- || event.data.id !== ID) {
- return;
- }
- if (event.data.userDetails) {
- resolveUserDetails(event.data.userDetails);
- } else if (event.data.world) {
- worlds[event.data.world.id] = event.data.world;
- const locations = document.getElementsByClassName('locations')[0];
- if (!locations) {
- return;
- }
- for (const [ instanceId ] of event.data.world.instances) {
- const locationLink = locations.querySelector(`.locations
- [href*=${CSS.escape(`/home/launch?worldId=${event.data.world.id}&instanceId=${instanceId}`)}]`);
- if (!locationLink) {
- continue;
- }
- insertInstanceUserCountAndCapacity(locationLink.closest('.locations > *'), event.data.world.id, instanceId);
- }
- }
- });
- /**
- * ログインしているユーザーの情報を取得します。
- * @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()}`));
- }
- let friendFavoriteGroupNameDisplayNamePairs;
- /**
- * ログインしているユーザーのフレンドfavoriteのグループ名を取得します。
- * @returns {Promise.<Object.<string>[]>}
- */
- function getFriendFavoriteGroupNameDisplayNamePairs()
- {
- if (!friendFavoriteGroupNameDisplayNamePairs) {
- friendFavoriteGroupNameDisplayNamePairs
- = fetchJSON('/api/1/favorite/groups?type=friend', {credentials: 'same-origin'}).then(function (groups) {
- const groupNameDisplayNamePairs = {};
- for (const group of groups) {
- groupNameDisplayNamePairs[group.name] = group.displayName;
- }
- return groupNameDisplayNamePairs;
- });
- }
- return friendFavoriteGroupNameDisplayNamePairs;
- }
- /**
- * @type {Promise.<Object.<(string|string[])>[]>}
- * @access private
- */
- let friendFavoritesPromise;
- /**
- * ブックマークを全件取得します。
- * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites}
- * @returns {Promise.<Object.<(string|string[])>[]>}
- */
- function getFriendFavorites()
- {
- return friendFavoritesPromise || (friendFavoritesPromise = async function () {
- const allFavorites = [];
- let offset = 0;
- while (true) { //eslint-disable-line no-constant-condition
- const favorites = await fetchJSON(
- `/api/1/favorites/?type=friend&n=${MAX_ITEMS_COUNT}&offset=${offset}`,
- ).catch(showError);
- allFavorites.push(...favorites);
- if (favorites.length < MAX_ITEMS_COUNT) {
- break;
- }
- offset += favorites.length;
- }
- return allFavorites;
- }());
- }
- /**
- * 自分のユーザーページに、ステータス変更コントロールを挿入します。
- * @returns {void}
- */
- function insertUpdateStatusControl()
- {
- if (document.getElementsByName('update-status')[0]) {
- // すでに挿入済みなら
- return;
- }
- const editStatusDescriptionControl = document.querySelector('[role="button"][title="Edit Status"]');
- if (!editStatusDescriptionControl) {
- return;
- }
- editStatusDescriptionControl.insertAdjacentHTML('beforebegin', `<select name="update-status">
- <option value="">Select Status</option>
- ${Object.keys(STATUSES)
- .map(status => h`<option value="${status}">⬤ ${STATUSES[status].label}</option>`).join('')}
- </select>`);
- editStatusDescriptionControl.previousElementSibling.addEventListener('change', async function (event) {
- if (event.target.value === '') {
- return;
- }
- event.target.disabled = true;
- try {
- const response = await fetch(location.pathname.replace('/home/user/', '/api/1/users/'), {
- method: 'PUT',
- headers: {'content-type': 'application/json'},
- credentials: 'same-origin',
- body: JSON.stringify({ status: event.target.value }),
- });
- if (!response.ok) {
- return Promise.reject(
- new Error(`${response.status} ${response.statusText}\n${await response.text()}`),
- );
- }
- } catch (exception) {
- showError(exception);
- } finally {
- event.target.value = '';
- event.target.disabled = false;
- }
- });
- }
- /**
- * フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。
- * @returns {Promise.<void>}
- */
- async function updateFriendFavoriteCounts()
- {
- const counts = {};
- for (const favorite of await getFriendFavorites()) {
- for (const tag of favorite.tags) {
- if (!(tag in counts)) {
- counts[tag] = 0;
- }
- counts[tag]++;
- }
- }
- for (const button of document.getElementsByName('favorite-friend')) {
- button.getElementsByClassName('count')[0].textContent = counts[button.value] || 0;
- }
- }
- /**
- * ユーザーページへブックマーク登録/解除ボタンを追加します。
- * @returns {Promise.<void>}
- */
- async function insertFriendFavoriteButtons()
- {
- const homeContent = document.getElementsByClassName('home-content')[0];
- const sibling = homeContent.querySelector('[role="group"]');
- if (!sibling) {
- return;
- }
- 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];
- if (!(await getUserDetails()).friends.includes(id)) {
- return;
- }
- const buttons = document.getElementsByName('favorite-friend');
- const groupNameDisplayNamePairs = await getFriendFavoriteGroupNameDisplayNamePairs();
- const groupNames = Object.keys(groupNameDisplayNamePairs);
- const buttonsParent = buttons[0] && buttons[0].closest('[role="group"]');
- if (buttonsParent) {
- // 多重挿入の防止
- if (buttonsParent.dataset.id === id) {
- return;
- } else {
- buttonsParent.remove();
- }
- }
- sibling.insertAdjacentHTML('afterend', `<div role="group" class="w-100 btn-group-lg btn-group-vertical mt-4"
- data-id="${h(id)}">
- ${groupNames.sort().map(tag => h`<button type="button"
- class="btn btn-secondary" name="favorite-friend" value="${tag}" disabled="">
- <span aria-hidden="true" class="fa fa-star"></span>
-  <span class="name">${groupNameDisplayNamePairs[tag]}</span>
-  <span class="count">‒</span>⁄${MAX_FRIEND_FAVORITE_COUNT_PER_GROUP}
- </button>`).join('')}
- </div>`);
- await updateFriendFavoriteCounts();
- const tags = [].concat(
- ...(await getFriendFavorites()).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_FRIEND_FAVORITE_COUNT_PER_GROUP) {
- button.disabled = false;
- }
- }
- buttons[0].closest('[role="group"]').addEventListener('click', async function (event) {
- const button = event.target.closest('button');
- if (!button || button.name !== 'favorite-friend') {
- return;
- }
- const buttons = document.getElementsByName('favorite-friend');
- 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 getFriendFavorites();
- 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: 'friend', 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 updateFriendFavoriteCounts();
- for (const button of buttons) {
- if (button.getElementsByClassName('count')[0].textContent < MAX_FRIEND_FAVORITE_COUNT_PER_GROUP) {
- button.disabled = false;
- }
- }
- });
- }
- /**
- * Friend Locationsページのインスタンスへ、現在のインスタンス人数と上限を表示します。
- * @param {HTMLDivElement} location
- * @returns {void}
- */
- function insertInstanceUserCountAndCapacity(location, worldId, instanceId)
- {
- const world = worlds[worldId];
- const instanceUserCount = world?.instances?.find(([ id ]) => id === instanceId)?.[1];
- /** @type {HTMLElement} */
- let counts = location.getElementsByClassName('instance-user-count-and-capacity')[0];
- if (!counts) {
- const friendCount = location.querySelector('[aria-label="Invite Me"]').parentElement.previousElementSibling;
- counts = friendCount.cloneNode();
- counts.classList.add('instance-user-count-and-capacity');
- counts.style.whiteSpace = 'nowrap';
- friendCount.before(counts);
- }
- counts.textContent = (instanceUserCount ?? '?') + ' / ' + (world?.capacity ?? '?');
- }
- /**
- * ページ読み込み後に一度だけ実行する処理をすでに行っていれば `true`。
- * @type {boolean}
- * @access private
- */
- let headChildrenInserted = false;
- const homeContents = document.getElementsByClassName('home-content');
- new MutationObserver(function (mutations, observer) {
- if (document.head && !headChildrenInserted) {
- headChildrenInserted = true;
- document.head.insertAdjacentHTML('beforeend', `<style>
- /*====================================
- Edit Profile
- */
- ` + Object.keys(STATUSES).map(status => `
- [name="update-status"].${CSS.escape(status.replace(' ', '-'))},
- [name="update-status"] option[value=${CSS.escape(status)}] {
- color: var(${STATUSES[status].color});
- }
- `).join('') + `
- /*====================================
- フレンドのユーザーページ
- */
- .btn[name^="favorite-"] {
- white-space: unset;
- }
- </style>`);
- // ユーザー情報・ワールド情報を取得
- GreasemonkeyUtils.executeOnUnsafeContext(function (id) {
- Response.prototype.text = new Proxy(Response.prototype.text, {
- apply(get, thisArgument, argumentList)
- {
- const textPromise = Reflect.apply(get, thisArgument, argumentList);
- (async function () {
- const data = { id };
- const pathname = new URL(thisArgument.url).pathname;
- if (pathname === '/api/1/auth/user') {
- data.userDetails = JSON.parse(await textPromise);
- } else if (pathname.startsWith('/api/1/worlds/wrld_')) {
- data.world = JSON.parse(await textPromise);
- }
- postMessage(data, location.origin);
- })();
- return textPromise;
- },
- });
- }, [ ID ]);
- }
- if (!homeContents[0]) {
- return;
- }
- const locationsList = homeContents[0].getElementsByClassName('locations');
- const instanceUserCountAndCapacityList = homeContents[0].getElementsByClassName('instance-user-count-and-capacity');
- new MutationObserver(async function (mutations) {
- for (const mutation of mutations) {
- if (locationsList[0]) {
- if (locationsList[0].children.length !== instanceUserCountAndCapacityList.length) {
- // Friend Locationsへインスタンス人数を追加
- for (const location of locationsList[0].children) {
- if (location.getElementsByClassName('instance-user-count-and-capacity')[0]) {
- continue;
- }
- const launchLink = location.querySelector('[href*="/home/launch?"]');
- if (!launchLink) {
- continue;
- }
- const params = new URLSearchParams(launchLink.search);
- insertInstanceUserCountAndCapacity(location, params.get('worldId'), params.get('instanceId'));
- }
- }
- } else if (mutation.addedNodes.length > 0 && mutation.target.nodeType === Node.ELEMENT_NODE
- && (/* ユーザーページを開いたとき */ mutation.target.classList.contains('home-content')
- || /* ワールドページを開いたとき */ mutation.target.parentElement.classList.contains('home-content'))
- || /* ユーザーページ間を移動したとき */ mutation.type === 'characterData'
- && mutation.target.parentElement.matches('.subheader *')) {
- if (location.pathname.startsWith('/home/user/')) {
- // ユーザーページ
- insertUpdateStatusControl();
- await insertFriendFavoriteButtons('friend');
- } else if (location.pathname.startsWith('/home/world/')) {
- // ワールドページ
- const heading = document.querySelector('.home-content h2');
- const name = heading.firstChild.data;
- const author
- = heading.nextElementSibling.querySelector('[href^="/home/user/usr_"]').firstChild.data;
- document.title = `${name} By ${author} - VRChat`;
- } else if (location.pathname.startsWith('/home/avatar/')) {
- // アバターページ
- const name = document.querySelector('.home-content h3').textContent;
- const author = document.querySelector('.home-content [href^="/home/user/usr_"]').text;
- document.title = `${name} By ${author} - VRChat`;
- } else if (location.pathname.startsWith('/home/group/')) {
- // グループページ
- const name = document.querySelector('.home-content h2').textContent;
- const shortCodeAndDiscriminator
- = document.querySelector('[href^="https://vrc.group/"]').textContent;
- document.title = `${name} ⁂ ${shortCodeAndDiscriminator} - VRChat`;
- }
- break;
- } else if (mutation.target.title === 'Edit Status' && mutation.target.getAttribute('role') === 'button') {
- // 自分のユーザーページのStatus Description入力欄へ履歴を追加
- if (mutation.addedNodes[0]?.placeholder === 'Set a new status!') {
- mutation.target.insertAdjacentHTML('beforeend', `<datalist id="status-description-history">
- ${(await getUserDetails())
- .statusHistory.map(statusDescription => h`<option>${statusDescription}</option>`).join('')}
- </datalist>`);
- mutation.addedNodes[0].setAttribute('list', 'status-description-history');
- } else if (mutation.removedNodes[0]?.placeholder === 'Set a new status!') {
- document.getElementById('status-description-history')?.remove();
- }
- }
- }
- }).observe(homeContents[0], {childList: true, characterData: true, subtree: true });
- observer.disconnect();
- }).observe(document, {childList: true, subtree: true});