VRChat Web Pages Extender

Add features into VRChat Web Pages and improve user experience.

目前为 2023-01-10 提交的版本。查看 最新版本

// ==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://gf.qytechs.cn/users/137
// @version     2.15.0
// @match       https://vrchat.com/home
// @match       https://vrchat.com/home?*
// @match       https://vrchat.com/home#*
// @match       https://vrchat.com/home/*
// @require     https://gf.qytechs.cn/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://gf.qytechs.cn/scripts/371331
// ==/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`<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 = { };

/**
 * キーにグループIDを持つ連想配列。
 * @type {Object.<string,(string|string[]|number|boolean|Object.<string,(string|string[]|boolean)?>)?>[]}
 */
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) {
		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);
		}
	} 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.<?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 id = getUserIdFromLocation();
	if (!id) {
		return;
	}

	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>
			&#xA0;<span class="name">${groupNameDisplayNamePairs[tag]}</span>
			&#xA0;<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;
			}
		}
	});
}

/**
 * ログイン中のユーザーのグループ一覧。
 * @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];
}

/**
 * ユーザーページへブグループ一覧を追加します。
 * @returns {void}
 */
function insertGroups()
{
	const userId = getUserIdFromLocation();
	if (!userId) {
		return;
	}

	const worldsRow = document.querySelector('#WorldGrid, [role="grid"][title="Worlds Loading"]')?.closest('.row');
	if (!worldsRow) {
		return;
	}

	let groupsRoot = document.getElementById('user-page-groups-root');
	if (groupsRoot) {
		// 多重挿入の防止
		if (groupsRoot.dataset.userId === userId) {
			return;
		} else {
			groupsRoot.remove();
		}
	}

	worldsRow.firstElementChild.firstElementChild.style.setProperty('padding-bottom', '1.5rem', 'important');

	const displayName = document.querySelector('.home-content h2').textContent;

	/*eslint-disable max-len */
	worldsRow.insertAdjacentHTML('afterend', h`<div class="row" id="user-page-groups-root" data-user-id="${userId}"
		style="padding-bottom: 3rem;">
		<div class="col-12"><div>
			<h3>
				<button type="button" name="toggle-groups" aria-label="${displayName}'s Groups Toggle"
					class="btn btn-secondary btn-md">
					<svg role="presentation" aria-hidden="true" class="svg-inline--fa fa-circle-plus"
						xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
						<path fill="currentColor" d="M0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512C114.6 512 0 397.4 0 256zM256 368C269.3 368 280 357.3 280 344V280H344C357.3 280 368 269.3 368 256C368 242.7 357.3 232 344 232H280V168C280 154.7 269.3 144 256 144C242.7 144 232 154.7 232 168V232H168C154.7 232 144 242.7 144 256C144 269.3 154.7 280 168 280H232V344C232 357.3 242.7 368 256 368z"></path>
					</svg>
				</button>&nbsp;<span class="display-name">${displayName}</span>'s Groups
				<button type="button" name="open-inviting-to-group" class="btn btn-primary">
					<svg aria-hidden="true" class="svg-inline--fa fa-envelope" role="presentation"
						xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
						<path fill="currentColor" d="M464 64C490.5 64 512 85.49 512 112C512 127.1 504.9 141.3 492.8 150.4L275.2 313.6C263.8 322.1 248.2 322.1 236.8 313.6L19.2 150.4C7.113 141.3 0 127.1 0 112C0 85.49 21.49 64 48 64H464zM217.6 339.2C240.4 356.3 271.6 356.3 294.4 339.2L512 176V384C512 419.3 483.3 448 448 448H64C28.65 448 0 419.3 0 384V176L217.6 339.2z"></path>
					</svg>
					Invite to Group
				</button>
			</h3>
			<div class="collapse"><div>
				<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;"></div>
				<div style="margin: 20px auto 0px; text-align: center;"></div>
			</div></div>
			<br>
		</div></div>
		<div id="user-page-inviting-to-group-dialog" tabindex="-1" hidden=""
			style="position: relative; z-index: 1050; display: block;"><div>
			<div class="modal fade show" style="display: block;" role="dialog" tabindex="-1">
				<div class="modal-dialog" role="document"><div class="modal-content">
					<div class="modal-header">
						<h5 class="modal-title"><h4 class="m-0">Invite to Group</h4></h5>
						<div><button name="close-inviting-to-group-dialog" aria-label="Close Button"
							style="padding: 5px; border-radius: 4px; border: 2px solid #333333; background: #333333;
								color: white;">
							<svg role="presentation" aria-hidden="true" class="svg-inline--fa fa-xmark fa-fw"
								xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" width="20">
								<path fill="currentColor" d="M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z"></path>
							</svg>
						</button></div>
					</div>
					<div class="modal-body"></div>
				</div></div>
			</div>
			<div class="modal-backdrop fade show"></div>
		</div></div>
	</div>`);
	/*eslint-enable max-len */

	groupsRoot = document.getElementById('user-page-groups-root');

	const groupsToggleButton = groupsRoot.getElementsByTagName('button')[0];
	const circlePlus = groupsToggleButton.firstElementChild;
	let circleMinus;
	const collapse = groupsRoot.getElementsByClassName('collapse')[0];
	const groupListParent = collapse.firstElementChild.firstElementChild;

	let groups;

	const dialog = document.getElementById('user-page-inviting-to-group-dialog');

	groupsRoot.addEventListener('click', async function (event) {
		const button = event.target.closest('button');
		if (!button) {
			return;
		}

		switch (button.name) {
			case 'toggle-groups':
				try {
					groupsToggleButton.disabled = true;

					if (collapse.classList.contains('show')) {
						// 閉じる
						groupsToggleButton.classList.replace('btn-primary', 'btn-secondary');
						circleMinus.replaceWith(circlePlus);
					} else {
						// 開く
						groupsToggleButton.classList.replace('btn-secondary', 'btn-primary');
						if (circleMinus) {
							circlePlus.replaceWith(circleMinus);
						} else {
							//eslint-disable-next-line max-len
							groupsToggleButton.innerHTML = '<svg role="presentation" aria-hidden="true" class="svg-inline--fa fa-circle-minus" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512C114.6 512 0 397.4 0 256zM168 232C154.7 232 144 242.7 144 256C144 269.3 154.7 280 168 280H344C357.3 280 368 269.3 368 256C368 242.7 357.3 232 344 232H168z"></path></svg>';
							circleMinus = groupsToggleButton.firstElementChild;

							if (!groups) {
								groups = await fetchUserGroups(userId);
							}
							if (groups.length > 0) {
								/*eslint-disable max-len */
								groupListParent.insertAdjacentHTML('afterbegin', groups.map(group => h`<a
									href="/home/group/${group.groupId}" style="margin-bottom: 0.8rem;
									text-decoration: none !important; border-radius: 8px; min-width: 0px;">
									<div style="padding: 0.1rem; background-color: #252A30;
										border: 3px 3px 0px #252A30 solid; border-radius: 8px 8px 0px 0px;">
										<div style="padding-top: 35%; border-radius: 8px; position: relative;">
											<img src="${group.bannerUrl}" alt="${group.name}" style="width: 100%;
												position: absolute; top: 0px; border-radius: 8px;
												aspect-ratio: 3 / 1; object-fit: cover;">
											<div style="position: absolute; bottom: -32px; margin-left: 13px;">
												<img src="${group.iconUrl}"
													style="width: 75px; height: 75px; border-radius: 100%;
														border: 3px solid #181B1F; background-color: #181B1F;">
											</div>
										</div>
										<div class="d-flex flex-row justify-content-between align-items-center">
											<button role="navigation" aria-label="Open World Page"
												style="background: none; border: medium none; padding: 0px;
												margin: 0px 0px 10px 100px; color: #0E9BB1; min-width: 0px;">
												<h4 style="font-size: 1.2em; margin-top: 0.25rem; margin-bottom: 0px;
													padding-bottom: 2px; color: white; overflow: hidden;
													text-overflow: ellipsis; white-space: nowrap;">${group.name}</h4>
											</button></div>
									</div>
									<div style="border: 0px 3px 3px #181B1F solid; background-color: #181B1F;
										padding: 5px; border-radius: 0px 0px 8px 8px; color: #737372;"><div>
										<div class="w-100 d-flex flex-column text-center justify-content-center
											align-content-center" style="height: 58px;">
											<small style="display: -webkit-box; -webkit-line-clamp: 3;
												-moz-box-orient: vertical; overflow: hidden; text-overflow: ellipsis;">
												${group.description}
											</small>
										</div>
										<div style="display: flex; justify-content: space-between; width: 100%;
											padding: 0px 20px;"><small>
											<svg role="presentation" aria-hidden="true" class="svg-inline--fa fa-users"
												xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512">
												<path fill="currentColor" d="M319.9 320c57.41 0 103.1-46.56 103.1-104c0-57.44-46.54-104-103.1-104c-57.41 0-103.1 46.56-103.1 104C215.9 273.4 262.5 320 319.9 320zM369.9 352H270.1C191.6 352 128 411.7 128 485.3C128 500.1 140.7 512 156.4 512h327.2C499.3 512 512 500.1 512 485.3C512 411.7 448.4 352 369.9 352zM512 160c44.18 0 80-35.82 80-80S556.2 0 512 0c-44.18 0-80 35.82-80 80S467.8 160 512 160zM183.9 216c0-5.449 .9824-10.63 1.609-15.91C174.6 194.1 162.6 192 149.9 192H88.08C39.44 192 0 233.8 0 285.3C0 295.6 7.887 304 17.62 304h199.5C196.7 280.2 183.9 249.7 183.9 216zM128 160c44.18 0 80-35.82 80-80S172.2 0 128 0C83.82 0 48 35.82 48 80S83.82 160 128 160zM551.9 192h-61.84c-12.8 0-24.88 3.037-35.86 8.24C454.8 205.5 455.8 210.6 455.8 216c0 33.71-12.78 64.21-33.16 88h199.7C632.1 304 640 295.6 640 285.3C640 233.8 600.6 192 551.9 192z"></path>
											</svg> ${group.memberCount}</small>
											<small>${group.shortCode}.${group.discriminator}</small>
										</div>
									</div></div>
								</a>`).join(''));
								/*eslint-enable max-len */
							} else {
								groupListParent.insertAdjacentHTML('beforebegin', `<div role="note"
									style="height: 200px; display: flex; justify-content: center; align-items: center;
										color: #54B5C5; background-color: #252A30; border-radius: 8px; font-size: 2rem;
										margin-top: 1rem;">
									There are no Groups here... yet
								</div>`);
							}
						}
					}

					collapse.classList.toggle('show');
				} catch (exception) {
					showError(exception);
					throw exception;
				} finally {
					groupsToggleButton.disabled = false;
				}
				break;

			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);
				}
				if (!groups) {
					groups = await fetchUserGroups(userId);
				}
				const groupIds = groups.map(group => group.groupId);

				if (!document.getElementById('invite-to-group-style')) {
					document.head.insertAdjacentHTML('beforeend', `<style id="invite-to-group-style">
						[name="invite-to-group"] {
							--icon-size: 30px;
							--padding: 5px;
							padding: var(--padding) calc(var(--icon-size) + 2 * var(--padding));
							font-size: 1.2em;
							border: 2px solid #064B5C;
							border-radius: 4px;
							position: relative;
							color: #6AE3F9;
							background: #064B5C;
							overflow: hidden;
							text-overflow: ellipsis;
							white-space: nowrap;
							width: 100%;
						}

						[name="invite-to-group"]:hover {
							border-color: #086C84;
						}

						[name="invite-to-group"]:disabled {
							border: 2px solid #333333;
							background: #333333;
							color: #999999;
						}

						[name="invite-to-group"] img {
							width: var(--icon-size);
							height: var(--icon-size);
							border-radius: 100%;
							border: 1px solid #181B1F;
							background-color: #181B1F;
							position: absolute;
							left: var(--padding);
						}

						[role="alert"] {
							display: flex;
							flex-direction: column;
							background-color: #541D22BF;
							margin-top: 10px;
							border-radius: 3px;
							padding: 10px;
							border-left: 3px solid red;
						}

						[role="alert"] > div:first-of-type {
							display: flex;
							align-items: center;
						}

						[role="alert"] > div:first-of-type > div:first-of-type {
							font-size: 1.2rem;
							font-weight: bold;
						}
					</style>`);
				}
				/*eslint-disable indent */
				modalBody.innerHTML = authUserGroups.map(group => h`<div
					class="mt-2 mb-2 d-flex flex-column justify-content-center">
					<div style="position: relative; border-radius: 4px;">
						<button name="invite-to-group" value="${h(group.groupId)}"` + (groupIds.includes(group.groupId)
							? h` disabled="" title="${displayName} is already a member of this group․"`
							: '') + h`>
							<img src="${group.iconUrl}">
							${group.name}
						</button>
					</div>
				</div>`).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`<div role="alert"
							aria-label="Couldn't invite user">
							<div>
								<svg aria-hidden="true" class="svg-inline--fa fa-circle-exclamation me-2"
									role="presentation"
									xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" color="red">
									<path fill="currentColor" d="M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM232 152C232 138.8 242.8 128 256 128s24 10.75 24 24v128c0 13.25-10.75 24-24 24S232 293.3 232 280V152zM256 400c-17.36 0-31.44-14.08-31.44-31.44c0-17.36 14.07-31.44 31.44-31.44s31.44 14.08 31.44 31.44C287.4 385.9 273.4 400 256 400z"></path>
								</svg>
								<div>Couldn't invite user</div>
							</div>
							<div>${response.statusText}: ${message}</div>
						</div>`);
						/*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 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;
				},
			});

			const responseText = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, 'responseText');
			responseText.get = new Proxy(responseText.get, {
				apply(get, thisArgument, argumentList)
				{
					const data = { id };
					const responseText = Reflect.apply(get, thisArgument, argumentList);
					if (thisArgument.responseURL) {
						const pathname = new URL(thisArgument.responseURL).pathname;
						if (pathname.startsWith('/api/1/groups/grp_')) {
							data.group = JSON.parse(responseText);
							postMessage(data, location.origin);
						}
					}
					return responseText;
				},
			});
			Object.defineProperty(XMLHttpRequest.prototype, 'responseText', responseText);
		}, [ 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.target.parentElement.parentElement
						.classList.contains('home-content'))
				|| /* ユーザーページ間を移動したとき */ mutation.type === 'characterData'
					&& mutation.target.parentElement?.nextElementSibling?.classList.contains('subheader')) {
				if (location.pathname.startsWith('/home/user/')) {
					// ユーザーページ
					insertUpdateStatusControl();
					insertGroups();
					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`<a id="group-owner-link" href="/home/user/${ownerId}">
								Group Owner
							</a>`;
							groupLinkColumn.after(column);
						}
					});
				}
				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});

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址