// ==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.18.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 // @downloadURL none // ==/UserScript== /*global Gettext, _, h, GreasemonkeyUtils */ 'use strict'; // L10N Gettext.setLocalizedTexts({ /*eslint-disable quote-props, max-len */ 'en': { 'エラーが発生しました': 'Error occurred', }, /*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`
`); } 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.} */ const MAX_FRIEND_FAVORITE_COUNT_PER_GROUP = 150; /** * @type {Function} * @access private */ let setUserDetails; /** * @type {Promise.} * @access private */ let userDetails = new Promise(function (resolve) { let settled = false; setUserDetails = function (details) { if (settled) { userDetails = Promise.resolve(details); } else { settled = true; resolve(details); } }; }); /** * キーにワールドIDを持つ連想配列。 * @type {Object.<(string|string[]|boolean|number|Object.<(string|number)>[]|(string|number)[][]|null)>} */ const worlds = { }; /** * キーにグループIDを持つ連想配列。 * @type {Object.)?>[]} */ const groups = { }; 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) { setUserDetails(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); } } else if (event.data.group) { groups[event.data.group.id] = event.data.group; } }); /** * ログインしているユーザーの情報を取得します。 * @see [User Info — VRChat API Documentation]{@link https://vrchatapi.github.io/#/UserAPI/CurrentUserDetails} * @returns {Promise.>} */ 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.[]>} */ 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.[]>} * @access private */ let friendFavoritesPromise; /** * ブックマークを全件取得します。 * @see [List Favorites — VRChat API Documentation]{@link https://vrchatapi.github.io/#/FavoritesAPI/ListAllFavorites} * @returns {Promise.[]>} */ 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 {Promise.} */ async function insertSelectStatusAndStatusMessageHistory() { if (document.getElementById('select-status')) { // すでに挿入済みなら return; } const inputStatusMessage = document.getElementById('input-status-message'); if (!inputStatusMessage) { return; } const statusMessageCell = inputStatusMessage.parentElement; const statusRow = statusMessageCell.parentElement; statusRow.classList.remove('tw-flex-col'); statusRow.classList.add('tw-flex-row', 'tw-gap-4'); /** @type {HTMLDivElement} */ const statusCell = statusMessageCell.cloneNode(true); statusMessageCell.classList.add('tw-grow'); statusCell.getElementsByTagName('input')[0].remove(); statusRow.prepend(statusCell); statusCell.insertAdjacentHTML( 'beforeend', ``, ); const selectStatus = statusCell.getElementsByTagName('select')[0]; const statusCellLabel = statusCell.getElementsByTagName('label')[0]; statusCellLabel.textContent = 'Status'; statusCellLabel.htmlFor = selectStatus.id; function updateInputStatusClass() { for (const status in STATUSES) { selectStatus .classList[status === selectStatus.value ? 'add' : 'remove'](status.replace(' ', '-')); } } selectStatus.addEventListener('change', updateInputStatusClass); selectStatus.value = (await getUserDetails()).status; updateInputStatusClass(); GreasemonkeyUtils.executeOnUnsafeContext(function (selectStatusId) { const targetURL = location.href.replace('/home/user/', '/api/1/users/'); globalThis.Request = new Proxy(globalThis.Request, { construct(target, argumentList, newTarget) { try { if (argumentList[0] === targetURL) { /** @type {RequestInit} */ const requestInit = argumentList[1]; if (requestInit?.method === 'PUT' && requestInit?.body) { const headers = requestInit.headers; if (typeof headers?.get === 'function' && headers?.get('content-type') === 'application/json') { const selectStatus = document.getElementById(selectStatusId); if (selectStatus) { requestInit.body = JSON.stringify(Object.assign(JSON.parse(requestInit.body), { status: selectStatus.value, })); } } } } } catch (exception) { console.error(exception); } return Reflect.construct(target, argumentList, newTarget); }, }); }, [ selectStatus.id ]); // ステータスメッセージ入力欄へ履歴を追加 inputStatusMessage.insertAdjacentHTML('afterend', ` ${(await getUserDetails()) .statusHistory.map(statusDescription => h``).join('')} `); inputStatusMessage.setAttribute('list', inputStatusMessage.nextElementSibling.id); } /** * フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。 * @returns {Promise.} */ 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.} */ async function insertFriendFavoriteButtons() { const homeContent = document.getElementsByClassName('home-content')[0]; const unfriendButton = homeContent.querySelector('[aria-label="Unfriend"]'); if (!unfriendButton) { return; } const id = getUserIdFromLocation(); if (!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(); } } unfriendButton.parentElement.parentElement.parentElement.parentElement.nextElementSibling.firstElementChild .insertAdjacentHTML('beforeend', `
${groupNames.sort().map(tag => h``).join('')}
`); 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; } } }); } /** * ログイン中のユーザーのグループ一覧。 * @type {(string|boolean|number)[]?} */ let authUserGroups; /** * 指定したユーザーが参加しているグループを取得します。 * @param {*} userId * @returns {Promise.<(string|boolean|number)[]>} */ function fetchUserGroups(userId) { return fetchJSON(`https://vrchat.com/api/1/users/${userId}/groups`); } /** * {@link location} からユーザーIDを抽出します。 * @see {@link https://github.com/vrcx-team/VRCX/issues/429#issuecomment-1302920703} * @returns {string?} */ function getUserIdFromLocation() { return /\/home\/user\/(usr_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-Za-z]{10})/ .exec(location.pathname)?.[1]; } /** * ユーザーページへブグループへのinviteボタンを追加します。 * @returns {void} */ function insertInvitingToGroupButton() { const userId = getUserIdFromLocation(); if (!userId) { return; } const groupsHeading = Array.from(document.querySelectorAll('.home-content h2')) .find(heading => heading.lastChild?.data === '\'s Groups'); if (!groupsHeading) { return; } if (document.getElementsByName('open-inviting-to-group')[0]) { return; } const displayName = document.querySelector('.home-content h2').textContent; /*eslint-disable max-len */ groupsHeading.insertAdjacentHTML('beforeend', h` `); /*eslint-enable max-len */ const dialog = document.getElementById('user-page-inviting-to-group-dialog'); groupsHeading.addEventListener('click', async function (event) { const button = event.target.closest('button'); if (!button) { return; } switch (button.name) { case 'open-inviting-to-group': { dialog.hidden = false; const modalBody = dialog.getElementsByClassName('modal-body')[0]; if (modalBody.firstElementChild) { break; } if (!authUserGroups) { authUserGroups = await fetchUserGroups((await getUserDetails()).id); } const groupIds = Array.from(groupsHeading.nextElementSibling.querySelectorAll('[aria-label="Group Card"]')) .map(groupCard => /grp_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ .exec(groupCard.pathname)[0]); if (!document.getElementById('invite-to-group-style')) { document.head.insertAdjacentHTML('beforeend', ``); } /*eslint-disable indent */ modalBody.innerHTML = authUserGroups.map(group => h`
`).join(''); /*eslint-enable indent */ break; } case 'invite-to-group': { const enabledButtons = Array.from(dialog.querySelectorAll('button:enabled')); try { for (const button of enabledButtons) { button.disabled = true; } const response = await fetch(`/api/1/groups/${button.value}/invites`, { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ userId, confirmOverrideBlock: true }), }); if (!response.ok) { const { error: { message } } = await response.json(); /*eslint-disable max-len */ button.parentElement.insertAdjacentHTML('beforebegin', h`
Couldn't invite user
${response.statusText}: ${message}
`); /*eslint-enable max-len */ } enabledButtons.splice(enabledButtons.indexOf(button), 1); } finally { for (const button of enabledButtons) { button.disabled = false; } } break; } case 'close-inviting-to-group-dialog': dialog.hidden = true; break; } }); } /** * 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 button = location.querySelector('[aria-label="Invite Me"]'); const friendCount = location.querySelector('[aria-label="Invite Me"]').parentElement.previousElementSibling; counts = friendCount.cloneNode(); counts.classList.add('instance-user-count-and-capacity'); const reloadButton = button.cloneNode(); reloadButton.setAttribute('aria-label', 'Reload'); reloadButton.textContent = '↺'; reloadButton.addEventListener('click', async function (event) { const instance = await fetchJSON(`/api/1/instances/${worldId}:${instanceId}`, { credentials: 'same-origin' }); event.target.previousSibling.data = instance.userCount + ' / ' + instance.capacity; }); counts.append('', reloadButton); friendCount.before(counts); } counts.firstChild.data = (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', ``); // ユーザー情報・ワールド情報・グループ情報を取得 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); } else if (pathname.startsWith('/api/1/groups/grp_')) { data.group = JSON.parse(await textPromise); } else { return; } 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.localName === 'div' && mutation.addedNodes.length === 1 && mutation.addedNodes[0].localName === 'div' && mutation.addedNodes[0] .querySelector('[aria-label="Add Friend"], [aria-label="Unfriend"]') || /* ワールドページを開いたとき */ mutation.target.parentElement.classList.contains('home-content') || /* グループページでタブ移動したとき */ mutation.target.parentElement.parentElement .classList.contains('home-content')) || /* ユーザーページ間を移動したとき */ mutation.type === 'characterData' && mutation.target.nextSibling?.data === '\'s Profile') { if (location.pathname.startsWith('/home/user/')) { // ユーザーページ await insertSelectStatusAndStatusMessageHistory(); insertInvitingToGroupButton(); 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/"]').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/"]').text; document.title = `${name} By ${author} - VRChat`; } else if (location.pathname.startsWith('/home/group/')) { // グループページ const name = document.querySelector('.home-content h2').textContent; const groupLink = document.querySelector('[href^="https://vrc.group/"]'); const shortCodeAndDiscriminator = groupLink.textContent; document.title = `${name} ⁂ ${shortCodeAndDiscriminator} - VRChat`; // グループオーナーへのリンクを追加 setTimeout(function () { if (!document.getElementById('group-owner-link')) { const groupLinkColumn = groupLink.closest('div'); groupLinkColumn.style.marginLeft = '1em'; const column = groupLinkColumn.cloneNode(); const ownerId = groups[/^\/home\/group\/([^/]+)/.exec(location.pathname)[1]].ownerId; column.innerHTML = h` Group Owner `; groupLinkColumn.after(column); } }); } break; } } }).observe(homeContents[0], {childList: true, characterData: true, subtree: true }); observer.disconnect(); }).observe(document, {childList: true, subtree: true});