// ==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.21.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',
},
/*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;
/**
* 一つのブックマークグループの最大登録数。
* @constant {Object.<number>}
*/
const MAX_FRIEND_FAVORITE_COUNT_PER_GROUP = 150;
/**
* 絵文字、ステッカーをアップロードする際の、画像ファイルの一辺の最大解像度。
* @constant
*/
const MAX_EMOJI_IMAGE_SIZE = 1024;
/**
* 絵文字、ステッカーをアップロードする際の、対応画像形式。
* @constant
*/
const EMOJI_IMAGE_TYPES = [ 'image/png', 'image/jpeg', 'image/svg+xml' ];
/**
* @type {Function}
* @access private
*/
let setUserDetails;
/**
* @type {Promise.<Object>}
* @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.<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) {
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.<?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 {Promise.<void>}
*/
async function insertStatusMessageHistory()
{
if (document.getElementById('input-status-message-history')) {
// すでに挿入済みなら
return;
}
const inputStatusMessage = document.getElementById('input-status-message');
if (!inputStatusMessage) {
return;
}
// ステータスメッセージ入力欄へ履歴を追加
inputStatusMessage.insertAdjacentHTML('afterend', `<datalist id="input-status-message-history">
${(await getUserDetails())
.statusHistory.map(statusDescription => h`<option>${statusDescription}</option>`).join('')}
</datalist>`);
inputStatusMessage.setAttribute('list', inputStatusMessage.nextElementSibling.id);
}
/**
* フレンドのブックマーク登録/解除ボタンの登録数表示を更新します。
* @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 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', `<div role="group" class="mx-2 btn-group-lg btn-group-vertical"
style="margin-top: -60px;"
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;
}
}
});
}
/**
* ログイン中のユーザーのグループ一覧。
* @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`
<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>
<div id="user-page-inviting-to-group-dialog" tabindex="-1" hidden=""
style="font-size: 1rem; line-height: initial; 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>
`);
/*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', `<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 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 ?? '?');
}
/**
* 画像ファイルを次のように変換し、指定されたinput要素へセットし直します。
* - 一辺 {@link MAX_EMOJI_IMAGE_SIZE} 以内に収まるように
* - 正方形でない場合は透明な余白を追加
* - {@link EMOJI_IMAGE_TYPES} 以外の形式、あるいは他の条件が満たされていなければ、PNGへ変換
* @param {WeakMap.<File>} alreadyConvertedFiles
* @param {File} file - 画像ファイル。
* @param {HTMLInputElement} input - `type="file"`
* @returns {Promise.<void>}
*/
async function convertImageFileForEmojiUploaderAndChangeInputElement(alreadyConvertedFiles, file, inputElement)
{
// 画像ファイルの読み込み
const img = new Image();
const reader = new FileReader();
await new Promise(function (resolve) {
img.addEventListener('load', function () {
resolve();
});
reader.addEventListener('load', function (event) {
img.src = event.target.result;
});
reader.readAsDataURL(file);
});
if (img.width === img.height
&& img.width <= MAX_EMOJI_IMAGE_SIZE && img.height <= MAX_EMOJI_IMAGE_SIZE
&& (EMOJI_IMAGE_TYPES.includes(file.type))) {
// 正方形、最大解像度以内、かつ対応形式なら
// そのまま返す
return file;
}
let width = img.width;
let height = img.height;
if (width > MAX_EMOJI_IMAGE_SIZE || height > MAX_EMOJI_IMAGE_SIZE) {
// 最大解像度を超えていれば
// アスペクト比を維持してリサイズする場合の解像度を算出
if (width > height) {
height = Math.round(height * (MAX_EMOJI_IMAGE_SIZE / width));
width = MAX_EMOJI_IMAGE_SIZE;
} else {
width = Math.round(width * (MAX_EMOJI_IMAGE_SIZE / height));
height = MAX_EMOJI_IMAGE_SIZE;
}
}
// 画像全体が収まる解像度の正方形のcanvasを作成
const canvas = document.createElement('canvas');
const sideLength = Math.max(width, height);
canvas.width = sideLength;
canvas.height = sideLength;
// 中央に画像を描画
canvas.getContext('2d').drawImage(img, (sideLength - width) / 2, (sideLength - height) / 2, width, height);
// PNG画像として出力し、新しい File オブジェクトを作成
const processedFile = new File([ await new Promise(function (resolve) {
canvas.toBlob(resolve, 'image/png');
}) ], 'image.png', { type: 'image/png' });
// 変換済みのファイルを記録
alreadyConvertedFiles.add(processedFile);
// 選択されたファイルを置き換える
const dataTransfer = new DataTransfer();
dataTransfer.items.add(processedFile);
inputElement.files = dataTransfer.files;
inputElement.dispatchEvent(new Event('change', { bubbles: true }));
}
/**
* ページ読み込み後に一度だけ実行する処理をすでに行っていれば `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>
/*====================================
Friend Locations
*/
.instance-user-count-and-capacity {
white-space: nowrap;
}
.instance-user-count-and-capacity button {
margin: 0 0 0 0.5em !important;
padding: unset;
line-height: 1;
}
/*====================================
フレンドのユーザーページ
*/
.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);
} 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 insertStatusMessageHistory();
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`<a id="group-owner-link" href="/home/user/${ownerId}">
Group Owner
</a>`;
groupLinkColumn.after(column);
}
});
}
break;
}
}
}).observe(homeContents[0], {childList: true, characterData: true, subtree: true });
// 絵文字、もしくはステッカーのアップロードページ
new MutationObserver(function () {
if (![ '/home/gallery/emoji', '/home/gallery/stickers' ].includes(location.pathname)) {
return;
}
/** @type {HTMLInputElement} */
const inputElement = document.getElementById('file');
if (!inputElement || inputElement.accept.includes('.webp')) {
return;
}
// WebPファイルを選択可能に
inputElement.accept += ',.webp';
/**
* 変換済みのファイル。
* @type {WeakSet.<File>}
*/
const alreadyConvertedFiles = new WeakSet();
// ファイル選択ダイアログによる画像ファイルの選択をトラップ
inputElement.addEventListener('change', function (event) {
const selectedFile = inputElement.files[0];
if (!selectedFile || alreadyConvertedFiles.has(selectedFile) || !selectedFile.type.startsWith('image/')) {
// ファイルが選択されていない、ファイル変換後に発生させたイベント、または画像ファイル以外が選択されていれば
return;
}
event.stopPropagation();
// 画像ファイルを変換し、再セット
convertImageFileForEmojiUploaderAndChangeInputElement(alreadyConvertedFiles, selectedFile, inputElement);
}, true);
// ドロップゾーンへのファイルのドロップをトラップ
inputElement.parentElement.parentElement.parentElement.addEventListener('drop', function (event) {
const droppedFile = event.dataTransfer.files[0];
if (!droppedFile || !droppedFile.type.startsWith('image/')) {
// ファイルがドロップされていない、または画像ファイル以外がドロップされていれば
return;
}
event.preventDefault();
event.stopPropagation();
// 画像ファイルを変換、input要素へセット
convertImageFileForEmojiUploaderAndChangeInputElement(alreadyConvertedFiles, droppedFile, inputElement);
});
}).observe(document.getElementById('home'), {childList: true});
observer.disconnect();
}).observe(document, {childList: true, subtree: true});