Skip to content

Instantly share code, notes, and snippets.

@RockRoller01
Last active October 17, 2023 14:49
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save RockRoller01/e0e10ff9e5716701e4a0b54f6bcddf42 to your computer and use it in GitHub Desktop.
Save RockRoller01/e0e10ff9e5716701e4a0b54f6bcddf42 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name osu-web enhanced
// @version 1.3.2
// @author RockRoller
// @match https://osu.ppy.sh/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @downloadURL https://gist.github.com/RockRoller01/e0e10ff9e5716701e4a0b54f6bcddf42/raw/script.user.js
// @updateURL https://gist.github.com/RockRoller01/e0e10ff9e5716701e4a0b54f6bcddf42/raw/script.user.js
// ==/UserScript==
'use strict';
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
function __rest(s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
}
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
/**
* Heroicons, used for CreateIcon
* MIT licensed, https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
*/
const arrow = `<svg viewBox="0 0 24 24" fill="none"><path d="M9 5L16 12L9 19" fill="#1c1719" stroke="#1c1719" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const at = `<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M14.243 5.757a6 6 0 10-.986 9.284 1 1 0 111.087 1.678A8 8 0 1118 10a3 3 0 01-4.8 2.401A4 4 0 1114 10a1 1 0 102 0c0-1.537-.586-3.07-1.757-4.243zM12 10a2 2 0 10-4 0 2 2 0 004 0z" clip-rule="evenodd"></path></svg>`;
const avatar = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`;
const bookmark = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path></svg>`;
const bookmarkFilled = `<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z"></path></svg>`;
const change = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path></svg>`;
const chat = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>`;
const close = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>`;
const collection = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>`;
const copy = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>`;
const documentAdd = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>`;
const documentFilled = `<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" ><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"></path></svg>`;
const download = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>`;
const externalLink = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>`;
const image = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`;
const message = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg>`;
const refresh = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>`;
const search = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>`;
const setting = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>`;
const trash = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>`;
const warning = `<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg>`;
const chevronDown = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>`;
const edit = `<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" ><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd"></path></svg>`;
const upload = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>`;
const pause = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`;
const play = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`;
const info = `<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`;
var icons = /*#__PURE__*/Object.freeze({
__proto__: null,
arrow: arrow,
at: at,
avatar: avatar,
bookmark: bookmark,
bookmarkFilled: bookmarkFilled,
change: change,
chat: chat,
close: close,
collection: collection,
copy: copy,
documentAdd: documentAdd,
documentFilled: documentFilled,
download: download,
externalLink: externalLink,
image: image,
message: message,
refresh: refresh,
search: search,
setting: setting,
trash: trash,
warning: warning,
chevronDown: chevronDown,
edit: edit,
upload: upload,
pause: pause,
play: play,
info: info
});
/**
* Exception thrown when ensuredSelectorand waitForElement can not assure the existance of the input
*/
class MissingElementException extends Error {
constructor(message) {
super(message);
this.name = "MissingElementException";
}
}
/**
* Exception thrown when GM_getValue returns undefined
*/
class UndefinedStorageValueException extends Error {
constructor(message) {
super(message);
this.name = "UndefinedStorageValueException";
}
}
/**
* waits for the supplied amount of time
* @param ms time in miliseconds
* @returns
*/
function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/**
* ensures that an element exists in the document and returns this element
* @param query any valid css selector, @see document.querySelector
* @throws MissingElementError when element can not be found in document, aka is null or undefined
* @returns selected element
*/
function ensuredSelector(query, parent = document) {
const element = parent.querySelector(query);
if (!element) {
throw new MissingElementException(`No element found for query: "${query}"`);
}
return element;
}
/**
* downloads a file by using HTML5 anchor download attribute
* @param url location of the file
* @param filename name of the file, defaults to timestamp
*/
function downloadUrl(url, filename = Date.now().toString()) {
return __awaiter(this, void 0, void 0, function* () {
const blobUrl = URL.createObjectURL(yield (yield fetch(url)).blob());
const anchor = createElement("a", {
attributes: {
href: blobUrl,
download: filename,
},
});
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(blobUrl);
});
}
/**
* creates an element from string by using innerHTML
* @param html any valid html as string
* @returns element
*/
function createElementFromHTMLString(html) {
return createElement("div", {
attributes: {
innerHTML: html,
},
}).firstChild;
}
/**
* easy creation of html elements with styles and attributes
* @param tagName HTML tag name of the element, e.g. "div"
* @param options @see CreateElementOptions
* @returns element
*/
function createElement(tagName, options = {}) {
const { attributes = {}, style = {}, className = "", children = [], events = [] } = options;
const element = document.createElement(tagName);
Object.assign(element, Object.assign(Object.assign({}, attributes), { className: `rr-lib ${className}` }));
Object.assign(element.style, style);
element.append(...children);
events.forEach((event) => element.addEventListener(event.type, event.handler));
return element;
}
/**
* waits until the given element is found
* @param query element to wait for
* @param parent element/document to wait within
* @returns the element
*/
function waitForElement(query, parent = document) {
return __awaiter(this, void 0, void 0, function* () {
for (let tries = 0; tries < 200; tries++) {
const element = parent.querySelector(query);
if (element) {
return element;
}
yield wait(100);
}
throw new MissingElementException(`waitForElement timed out for: ${query}`);
});
}
/**
* inserts the given style rule into the document
* @param styleText text of one or more css rules
*/
function insertStyleTag(styleText) {
ensuredSelector("head").append(createElement("style", {
attributes: {
innerHTML: styleText,
},
}));
}
/**
* creates an icon, all icons are in icons.ts
* @param name key of the icon in icons.ts
* @param options @see CreateIconOptions
* @returns icon element
*/
function createIcon(name, options = {}) {
const { size = 16 } = options, rest = __rest(options, ["size"]);
const element = createElementFromHTMLString(icons[name]);
Object.assign(element.style, Object.assign({ width: `${size}px`, height: `${size}px` }, rest));
return element;
}
/**
* defines a toggle input element
*/
class ToggleInput extends HTMLDivElement {
/**
* constructor
* @param clickFunction the function to be executed when this toggle is clicked
*/
constructor(clickFunction) {
super();
this.classList.add("rr-toggle");
this.addEventListener("click", clickFunction);
this.append(createElement("div", {
className: "rr-toggle__background",
}));
this.append(createElement("div", {
className: "rr-toggle__knob",
}));
}
/**
* set state inactive
*/
setActive() {
this.classList.add("active");
}
/**
* set state inactive
*/
setInActive() {
this.classList.remove("active");
}
/**
* toggle active state
*/
toggleState() {
this.classList.contains("active") ? this.classList.remove("active") : this.classList.add("active");
}
}
/**
* This object defines a base modal button with "click" EventListener.
*/
class BaseModalButton extends HTMLButtonElement {
/**
* constructor
* @param label label of the button
* @param clickFunction "click" event function
* @param classModifier optional class modifier for this element
*/
constructor(label, clickFunction, classModifier) {
super();
this.classList.add("rr-modal__button");
if (classModifier) {
this.classList.add(`rr-modal__button--${classModifier}`);
}
this.innerText = label;
this.addEventListener("click", clickFunction);
}
}
class BaseModal extends HTMLDivElement {
/**
* constructor
* @param header @see BaseModalHeader
*/
constructor(header) {
super();
this.header = header;
/**
* Container containing all the iconButtons
*/
this.buttonContainer = createElement("div", {
className: "rr-modal__buttons-container",
style: {
display: "flex",
gap: "8px",
},
});
/**
* Container containing the actual modal content
*/
this.contentBox = createElement("div", {
style: {
gap: "inherit",
display: "flex",
flexDirection: "column",
margin: "0 8px",
maxHeight: "60vh",
overflowY: "auto",
},
className: "rr-modal__content-box",
});
/**
* Default close button
*/
this.closeButton = new BaseModalButton("close", () => {
this.parentElement.unmount();
});
this.classList.add("rr-modal");
this.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
});
this.style.margin = "auto";
this.buttonContainer.append(this.closeButton);
this.append(this.header);
this.append(this.contentBox);
this.append(this.buttonContainer);
}
/**
* This method adds content to the bottom of the modals content box
* @param content any HTMLElement
*/
addContent(content) {
this.contentBox.append(content);
content.scrollIntoView();
}
/**
* This method adds a new button to the modal. New buttons appear to the left of existing ones by default.
* @see ModalButton
* @param button @see BaseModalButton
* @param after defines if the elements should appear to the right of existing buttons, false by default
*/
addModalButton(button, after = false) {
if (after) {
this.buttonContainer.append(button);
}
else {
this.buttonContainer.prepend(button);
}
}
/**
* adds an iconButton to the header, for details please see @see BaseModalHeader
* @param iconName @see BaseModalHeader.addIconButton
* @param tooltip @see BaseModalHeader.addIconButton
* @param eventFunction @see BaseModalHeader.addIconButton
*/
addIconButton(iconName, tooltip, eventFunction) {
this.header.addIconButton(iconName, tooltip, eventFunction);
}
/**
* This method updates the title of the header.
* @param title title to be displayed
*/
setTitle(title) {
this.header.setTitle(title);
}
}
/**
* This object describes a base header of a Modal.
*/
class BaseModalHeader extends HTMLDivElement {
/**
* constructor
* @param title title that the header should display
*/
constructor(title) {
super();
/**
* The span displaying the title.
*/
this.headerLabel = createElement("span", {
className: "rr-modal__header-label",
});
/**
* The div containing all the icon buttons @see addIconButton
*/
this.iconContainer = createElement("div", {
className: "rr-modal__header-buttons",
style: {
display: "flex",
alignItems: "center",
gap: "8px",
},
});
this.classList.add("rr-modal__header");
this.headerLabel.innerText = title;
this.style.display = "flex";
this.style.justifyContent = "space-between";
this.style.alignItems = "center";
this.append(this.headerLabel);
this.append(this.iconContainer);
}
/**
* This method adds an iconButton to the header.
* More than one icon should be possible.
* @param iconName icon to be used
* @param tooltip tooltip for the button
* @param clickFunction function for "click" EventListener
*/
addIconButton(iconName, tooltip, clickFunction) {
this.iconContainer.append(createElement("div", {
style: {
display: "flex",
cursor: "pointer",
},
attributes: {
title: tooltip,
onclick: clickFunction,
},
children: [createIcon(iconName)],
}));
}
/**
* This method updates the title of the header.
* @param headerTitle title to be displayed
*/
setTitle(headerTitle) {
this.headerLabel.innerText = headerTitle;
}
}
/**
* Defines the wrapper for all modals.
* This will auto remove itself (and the containing modal) if a click off the modal happens onto it.
* .mount() and .unmount() must be called for it to appear/vanish
*/
class ModalWrapper extends HTMLDivElement {
/**
* constructor
* @param modal modal this wrapper should contain initially
*/
constructor(modal) {
super();
this.modal = modal;
this.append(this.modal);
// prevent page background to be scrollable
insertStyleTag(`.modal-body-freeze { overflow-y: hidden }`);
this.classList.add("rr-modal-wrapper");
this.addEventListener("click", () => {
this.unmount();
});
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
this.unmount();
}
});
this.style.display = "flex";
this.style.position = "fixed";
this.style.top = "0";
this.style.left = "0";
this.style.width = "100%";
this.style.height = "100%";
this.style.zIndex = "1000";
this.style.backgroundColor = "rgba(0, 0, 0, 0.5)";
this.style.opacity = "1";
}
/**
* makes this wrapper and its modal visible, has fade-in
*/
mount() {
document.body.append(this);
document.body.classList.add("modal-body-freeze");
}
/**
* makes this wrapper and its modal invisible, has fade-out
*/
unmount() {
document.body.classList.remove("modal-body-freeze");
this.remove();
}
/**
* allows to replace the modal without needing to re-mount a modalwrapper and therefore triggering the animation again
* @param modal modal to change to, @see BaseModal
*/
replaceModal(modal) {
this.modal.replaceWith(modal);
this.modal = modal;
}
}
const osuTheme = {
successColor: "#b3d944",
failureColor: "#d94444",
normalTextColor: "hsl(var(--hsl-c1))",
notificationBackground: "hsl(var(--hsl-b4))",
secondaryBackground: "hsl(var(--hsl-b4))",
modalBackground: "hsl(var(--hsl-b2))",
inputMinWidth: "250px",
osuPink: "hsl(var(--hsl-h1))",
};
/**
* osu! styled modal
*/
class OsuModal extends BaseModal {
/**
* constructor
* @param modalHeader header of this modal, @see OsuModalHeader
*/
constructor(modalHeader) {
super(modalHeader);
this.closeButton.classList.add("btn-osu-big", `btn-osu-big--forum-secondary`);
this.closeButton.innerText = "Close";
this.style.padding = "16px";
this.style.minWidth = "400px";
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.gap = "8px";
this.style.borderRadius = "4px";
this.style.backgroundColor = osuTheme.modalBackground;
}
}
/**
* osu! styled modal button
*/
class OsuModalButton extends BaseModalButton {
/**
* constructor
* @param label @see BaseModalButton
* @param clickFunction @see BaseModalButton
* @param buttonType osu! button types @see osuButtonType
* @param classModifer @see BaseModalButton
*/
constructor(label, clickFunction, buttonType = "primary", classModifer) {
super(label, clickFunction);
this.classList.add("btn-osu-big", `btn-osu-big--forum-${buttonType}`);
}
}
/**
* osu! styled modal header
*/
class OsuModalHeader extends BaseModalHeader {
/**
* constructor
* @param title @see BaseModalHeader
*/
constructor(title) {
super(title);
this.style.fontWeight = "700";
this.style.fontSize = "1.25em";
}
}
/**
* This object defines all the aditional bbcode options and what icon should be used for them.
* The key is the BBCode tag, the value the icon
*/
const additionalBBCode = {
u: "fas fa-underline",
color: "fas fa-paint-brush",
spoiler: "fas fa-stream",
quote: "fas fa-quote-right",
centre: "fas fa-align-center",
youtube: "fab fa-youtube",
audio: "fas fa-music",
notice: "fas fa-comment-alt",
code: "fas fa-code",
profile: "fas fa-user",
};
/**
* This object defines all the settings for the script.
* Settings are grouped into categories according to the area they are used in.
* If a section of the osu! page has at least 2 settings related to it they should be grouped into one section.
*/
const settings = {
profile: {
showExpandMe: {
storageKey: `settings-showExpandMe`,
label: `Show button to expand me! pages`,
default: true,
},
convertRankToLink: {
storageKey: `settings-convertRankToLink`,
label: `Convert global / country rank numbers above the ranking graph to links to rankings page`,
default: true,
extraInfo: `This only works for the top 10.000, as ranking pages only show up to #10.000.`,
},
},
forum: {
showSendMessage: {
storageKey: `settings-showSendMessage`,
label: `Display 'Send Message' button on every forum post`,
default: true,
},
showAdditionalBBCode: {
storageKey: `settings-showAdditionalBBCode`,
label: `Show additional BBCode buttons when creating a new topic/reply`,
default: false,
},
highlightOwnName: {
storageKey: `settings-highlightOwnName`,
label: `Highlight own name on forum listing and threads`,
default: false,
},
insertQuoteAtCursorPosition: {
storageKey: `settings-insertQuoteAtCursorPosition`,
label: `Insert quotes at current cursor position in topic reply box`,
default: true,
},
showMessageManager: {
storageKey: `settings-showForumManager`,
label: `Show forum template manager`,
default: true,
extraInfo: `The forum template manager allows you to create, manage and insert templates for forum posts.`,
},
quoteSelectedText: {
storageKey: `settings-quoteSelectedText`,
label: `Quote by selecting text`,
default: true,
},
},
beatmaps: {
showDownloadOnDiscussion: {
storageKey: `settings-showDownloadOnDiscussion`,
label: `Show download buttons on discussion page`,
default: true,
},
showDotOsuData: {
storageKey: `settings-showDotOsuData`,
label: `Show 'download .osu data' button`,
default: true,
},
showExpandDescription: {
storageKey: `settings-showExpandDescription`,
label: `Show 'view full description' button`,
default: true,
},
showDetailedInfo: {
storageKey: `settings-showDetailedInfo`,
label: `Show 'view detailed info' button`,
default: true,
},
showBeatmapCoverButton: {
storageKey: `settings-showBeatmapCoverButton`,
label: `Show 'open background' button`,
default: true,
},
showOMDBBUtton: {
storageKey: `settings-showOMDBBUtton`,
label: `Show 'rate on OMDB' button`,
default: true,
},
},
other: {
showMessageManager: {
storageKey: `settings-showChatManager`,
label: `Show chat manager.`,
default: true,
extraInfo: `The chat manager allows you to create, manage and send templates for chat messages`,
},
logAPIdata: {
storageKey: `settings-logAPIdata`,
label: `Log API data to console on all pages`,
default: false,
extraInfo: `Many pages on the osu! website use hidden <script> tags whose text content contains API responses for the current page, which will get logged to the browser console if this setting is enabled.`,
},
openNonPPYLinksExternal: {
storageKey: `settings-openNonPPYLinksExternal`,
label: `Open all links that aren't on ppy.sh in a new tab`,
default: true,
},
hideNotificationCount: {
storageKey: `settings-hideNotifications`,
label: `Removes the notification count from the tab title`,
default: false,
},
},
};
/**
* map of all the keys for customMessages
* would just use the type-template, but that wasn't used for chat so i chose this approach rather than having to reformat all messages
*/
const templateKeys = {
forum: "forum-template",
chat: "message",
};
/**
* set the specified variable in greasemonkey storage
* @param key storage key
* @param value value
*/
function gmSetValue(key, value) {
GM_setValue(key, value);
}
/**
* get the specified variable from greasemonkey storage
* @param key storage key
* @returns value from storage
* @throws UndefinedStorageValueException if GM/TM fails and finds an undefined value
*/
function gmGetValue(key) {
const value = GM_getValue(key);
if (value == undefined) {
throw new UndefinedStorageValueException("Undefined value for key: " + key);
}
else {
return value;
}
}
/**
* get all values from greasemonkey storage
* @returns
*/
function gmListValues() {
return GM_listValues();
}
/**
* delete specified variable from greasemonkey storage
* @param key storage key
*/
function gmDeleteValue(key) {
GM_deleteValue(key);
}
class OsuInputLabel extends HTMLLabelElement {
/**
*
* @param id ID to connect input and label
* @param input Input connected to this label.
* @param label Label of the label
*/
constructor(id, input, label, extraInfo) {
super();
input.id = id;
this.htmlFor = id;
this.innerText = label;
this.style.display = "flex";
this.style.alignItems = "center";
this.style.justifyContent = "space-between";
this.style.minWidth = "72px";
this.style.padding = "0px";
this.style.color = osuTheme.normalTextColor;
this.style.fontSize = "14px";
this.style.textTransform = "unset";
this.style.lineHeight = "unset";
this.style.fontWeight = "400";
this.style.marginBottom = "0";
this.style.gap = "8px";
if (extraInfo) {
this.append(createElement("span", {
children: [createIcon("info")],
attributes: {
title: extraInfo,
},
style: {
color: "rgba(255, 255, 255, 0.5)",
marginRight: "auto",
display: "flex",
},
}));
}
this.append(input);
}
}
/**
* This function creates a styled toggle input without an EventListener.
* @param callback onClick function
* @returns toggle
*/
function createToggleInput(callback = () => { }) {
const toggle = createElement("div", {
className: "toggle",
style: {
height: "14px",
background: osuTheme.modalBackground,
width: "14px",
position: "absolute",
top: "3px",
right: "15px",
borderRadius: "100%",
transition: "200ms ease transform",
},
});
const toggleBackground = createElement("div", {
className: "toggle-bg",
style: {
height: "20px",
background: osuTheme.secondaryBackground,
width: "32px",
borderRadius: "12px",
border: `1px solid ${osuTheme.failureColor}`,
transition: "200ms ease background-color",
display: "inline-block",
},
});
const container = createElement("div", {
className: "toggle-input",
style: {
display: "flex",
position: "relative",
cursor: "pointer",
},
children: [toggle, toggleBackground],
attributes: {
onclick: () => callback(),
},
});
return container;
}
/**
* This function creates a styled textarea.
* @returns The styled textarea.
*/
function createTextAreaInput(classString, text) {
return createElement("textarea", {
className: `${classString} bbcode-editor__body`,
attributes: {
value: text,
rows: 2,
},
style: {
minHeight: "27px",
backgroundColor: osuTheme.secondaryBackground,
borderRadius: "4px",
padding: "4px",
},
});
}
function insertSettingsModalOpenButton() {
return __awaiter(this, void 0, void 0, function* () {
yield waitForElement(".nav2__col--avatar .simple-menu");
do {
yield wait(200);
const settingsButton = document.querySelector(".rr-lib.settings-button-enhanced");
if (settingsButton == null) {
ensuredSelector(".nav2__col--avatar .simple-menu").append(createSettingsModalOpenButton());
}
else {
settingsButton.onclick = onSettingsClick;
}
yield wait(500);
} while (document.querySelector(".rr-lib.settings-button") == null);
});
}
/**
* This function initialises all settings by looping over all of them and writing them to storage.
*/
function initSettings() {
// splits the object into multiple arrays, one for each category and loops over them
Object.entries(settings).map((category) => {
// [0] is the key, [1] is the value that I want
const options = category[1];
// splits the category into the separate settings and loops over them
Object.entries(options).map((s) => {
// [0] is the key, [1] is the setting object that I want
const object = s[1];
// writes the setting if it doesnt exist, in which case it will throw the error
try {
gmGetValue(object.storageKey);
}
catch (e) {
if (e instanceof UndefinedStorageValueException) {
gmSetValue(object.storageKey, object.default);
}
else {
throw e;
}
}
});
});
gmSetValue(`customMessage-fallback`, JSON.stringify({
messageOptions: {
message: "Hello world!",
title: "Hello world!",
},
storageKey: `customMessage-fallback`,
}));
}
/**
* This function resets the saved value for all settings to their default value.
*/
function resetSettings() {
Object.entries(settings).map((category) => {
const options = category[1];
Object.entries(options).map((s) => {
const object = s[1];
// overwrites the setting wth the default value
gmSetValue(object.storageKey, object.default);
});
});
}
/**
* This function resets all toggles to their written states.
*/
function updateToggles() {
const toggles = document.querySelectorAll(".rr-lib.toggle-input");
const settingStates = [];
Object.entries(settings).map((category) => {
const options = category[1];
Object.entries(options).map((s) => {
const object = s[1];
// overwrites the setting wth the default value
settingStates.push(gmGetValue(object.storageKey));
});
});
Array.from(toggles).map((t, i) => {
if (settingStates[i] == true) {
t.classList.add("toggle-active");
}
else if (settingStates[i] == false) {
t.classList.remove("toggle-active");
}
});
}
/**
* This function inserts the settings modal.
*/
function insertSettingsModal() {
insertStyleTag(`
.toggle-active .toggle { right: 3px !important }
.toggle-active .toggle-bg { border-color: ${osuTheme.successColor} !important }
.rr-lib.settings-tab.active { color: ${osuTheme.normalTextColor}; font-weight: bold }
.rr-lib.settings-tab.active:after { content: ""; display: block; border-radius: 5px; height: 5px; background-color: ${osuTheme.osuPink}; position: absolute; width: 100%; left: 0px; bottom: -3px }
.rr-modal .settings-modal__tabbed-pane-header.selected { color: ${osuTheme.osuPink} !important }
.rr-modal .settings-modal__tabbed-pane-header:hover:not(.selected) { color: ${osuTheme.normalTextColor} !important }
`);
const modal = new OsuModal(new OsuModalHeader("Script Settings"));
const modalWrapper = new ModalWrapper(modal);
modal.addIconButton("refresh", "Reset Settings", () => {
if (window.confirm("Do you want to reset all settings?")) {
resetSettings();
updateToggles();
}
});
const paneSelectors = createElement("div", {
className: "settings-modal__tabbed-pane-headers-container",
style: {
display: "flex",
gap: "inherit",
borderBottom: `1px solid ${osuTheme.osuPink}`,
},
});
const pane = createElement("div", {
className: "settings-modal__pane",
style: {
display: "flex",
flexDirection: "column",
gap: "inherit",
width: "500px",
height: "500px",
padding: "0 8px",
},
});
Object.entries(settings).forEach((category, ii) => {
// category[0] is the title of the category, converting that to uppercase for the header
const sectionName = category[0];
const selector = createElement("span", {
className: `settings-modal__tabbed-pane-header`,
attributes: {
innerText: sectionName.charAt(0).toUpperCase() + sectionName.slice(1),
onclick: () => {
paneSelectors.childNodes.forEach((node) => node.classList.remove("selected"));
pane.innerHTML = "";
selector.classList.add("selected");
// category[1] is an object containing all the settings for that category
Object.entries(category[1]).forEach((s) => {
const setting = s[1];
const toggle = createToggleInput(() => {
gmSetValue(setting.storageKey, !gmGetValue(setting.storageKey));
toggle.classList.toggle("toggle-active");
});
if (gmGetValue(setting.storageKey) == true) {
toggle.classList.toggle("toggle-active");
}
pane.append(new OsuInputLabel(setting.storageKey, toggle, setting.label, setting.extraInfo));
});
},
},
style: {
cursor: "pointer",
padding: "4px",
color: "rgba(255, 255, 255, 0.5)",
transition: "ease all 200ms",
userSelect: "none",
},
});
if (ii == 0) {
selector.click();
}
paneSelectors.append(selector);
});
modal.addContent(paneSelectors);
modal.addContent(pane);
ensuredSelector("body").append(modalWrapper);
}
function createSettingsModalOpenButton() {
return createElement("a", {
className: "simple-menu__item settings-button-enhanced",
attributes: {
innerText: "osu-web enhanced",
onclick: () => onSettingsClick(),
},
});
}
function onSettingsClick() {
var _a;
(_a = document.querySelector(".nav2__col--avatar .simple-menu")) === null || _a === void 0 ? void 0 : _a.classList.add("hidden");
insertSettingsModal();
}
function useFeature(storageKey, feature) {
if (gmGetValue(storageKey) == true) {
feature();
}
}
/**
* This functions intitialises all the beatmap related modifications.
*/
function insertBeatmapModifications() {
return __awaiter(this, void 0, void 0, function* () {
do {
yield wait(500);
if (!location.pathname.includes("discussion")) {
yield waitForElement(".beatmapset-header__buttons");
useFeature(settings.beatmaps.showDotOsuData.storageKey, insertGetDotOsuButton);
useFeature(settings.beatmaps.showExpandDescription.storageKey, insertBeatmapDescriptionExpander);
const apiInfo = JSON.parse((yield waitForElement("#json-beatmapset")).innerHTML);
useFeature(settings.beatmaps.showOMDBBUtton.storageKey, () => {
insertBeatmapInfoHeaderButton("database", "Rate on OMDB", "open-omdb-button", () => {
window.open(`https://omdb.nyahh.net/mapset/${apiInfo.id}`);
});
});
useFeature(settings.beatmaps.showBeatmapCoverButton.storageKey, () => {
insertBeatmapInfoHeaderButton("image", "Open background", "open-cover-button", () => {
window.open(`https://assets.ppy.sh/beatmaps/${apiInfo.id}/covers/raw.jpg`);
});
});
}
else {
yield waitForElement(".beatmap-discussions-header-bottom__content");
useFeature(settings.beatmaps.showDownloadOnDiscussion.storageKey, insertDownloadButtonsOnDiscussion);
}
useFeature(settings.beatmaps.showDetailedInfo.storageKey, insertDetailedInfoButton);
yield wait(2000);
} while (location.pathname.split("/")[1] === "beatmapsets");
});
}
/**
* adds beatmap dl buttons to the discussion page
*/
function insertDownloadButtonsOnDiscussion() {
if (document.querySelector(".discussion-download") != null) {
return;
}
const target = ensuredSelector(".beatmap-discussions-header-bottom__content");
const discussionInfo = JSON.parse(ensuredSelector("#json-beatmapset-discussion").innerText);
const container = createElement("div", {
className: "beatmap-discussions-header-bottom__details discussion-download",
style: {
display: "flex",
gap: "10px",
minHeight: "32.5px",
},
});
container.append(createDlButton(`https://osu.ppy.sh/beatmapsets/${discussionInfo.beatmapset.id}/download`, "Download"));
if (discussionInfo.beatmapset.video) {
container.append(createDlButton(`https://osu.ppy.sh/beatmapsets/${discussionInfo.beatmapset.id}/download?noVideo=1`, "No Video"));
}
container.append(createDlButton(`osu://b/${discussionInfo.beatmapset.beatmaps[0].id}`, "osu!direct"));
target.append(container);
function createDlButton(url, label) {
const el = createElement("a", {
className: "btn-osu-big btn-osu-big--full",
children: [
createElement("span", {
attributes: {
innerText: label,
},
style: {
maxWidth: "max-content",
paddingLeft: "4px",
},
}),
createElement("span", { className: "fas fa-download", style: { paddingRight: "10px" } }),
],
attributes: {
href: url,
},
style: {
padding: "6px",
display: "flex",
gap: "4px",
alignItems: "center",
justifyContent: "space-between",
},
});
// dont ask me what turbolinks is, but it makes the download work
el.dataset.turbolinks = "false";
return el;
}
}
/**
* This function gets the .osu data for the currently selected difficulty
*/
function insertGetDotOsuButton() {
if (document.querySelector(".get-osu-data") != null) {
return;
}
insertBeatmapInfoHeaderButton("file-code", "Download .osu data", "get-osu-data", () => {
downloadUrl(`https://osu.ppy.sh/osu/${window.location.toString().split("/")[window.location.toString().split("/").length - 1]}`, Date.now() + ".osu");
});
}
/**
* inserts a modal that displays detailed metadata info for the beatmap
*/
function insertDetailedInfoButton() {
if (document.querySelector(".detailed-info") != null) {
return;
}
if (window.location.pathname.includes("discussion")) {
// dicussion page
const info = JSON.parse(ensuredSelector("#json-beatmapset-discussion").innerText).beatmapset;
ensuredSelector(".beatmap-discussions-header-bottom__content").append(createElement("div", {
className: "beatmap-discussions-header-bottom__details discussion-download",
style: {
display: "flex",
gap: "10px",
minHeight: "32.5px",
},
children: [
createElement("button", {
className: "btn-osu-big btn-osu-big--full detailed-info",
children: [
createElement("span", {
attributes: {
innerText: "Detailed Beatmap Info",
},
style: {
paddingLeft: "4px",
},
}),
createElement("span", { className: "fas fa-info-circle", style: { paddingRight: "10px" } }),
],
style: {
padding: "6px",
display: "flex",
gap: "4px",
alignItems: "center",
justifyContent: "space-between",
},
events: [
{
type: "click",
handler: () => {
openBeatmapDetailedInfoModal(info);
},
},
],
}),
],
}));
}
else {
// beatmap info page
const info = JSON.parse(ensuredSelector("#json-beatmapset").innerText);
insertBeatmapDescriptionHoverButton("eye", "View detailed information", "detailed-info", () => {
openBeatmapDetailedInfoModal(info);
}, ensuredSelector(".beatmapset-info__box:nth-child(2)"));
}
}
function openBeatmapDetailedInfoModal(info) {
const modal = new OsuModal(new OsuModalHeader("Detailed Metadata Info"));
const modalWrapper = new ModalWrapper(modal);
modal.style.maxWidth = "40vw";
modal.addContent(createInfoRow("Artist", info.artist));
modal.addContent(createInfoRow("Artist (Unicode)", info.artist_unicode));
modal.addContent(createInfoRow("Source", info.source));
modal.addContent(createInfoRow("Title", info.title));
modal.addContent(createInfoRow("Title (Unicode)", info.title_unicode));
modal.addContent(createElement("div", {
style: {
display: "flex",
flexDirection: "column",
gap: "4px",
},
children: [
createElement("span", {
style: {
fontWeight: "bold",
},
attributes: {
innerText: `Tags:`,
},
}),
createElement("div", {
style: {
display: "flex",
gap: "4px",
flexWrap: "wrap",
},
children: [
...info.tags.split(" ").map((tag) => {
return createElement("span", {
style: {
padding: "2px 4px",
backgroundColor: "#22282a",
borderRadius: "2px",
},
attributes: {
innerText: tag,
},
});
}),
],
}),
],
}));
document.body.append(modalWrapper);
function createInfoRow(label, content) {
return createElement("div", {
style: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "4px",
borderBottom: "solid 1px rgba(255, 255, 255, 0.1)",
},
children: [
createElement("span", {
style: {
fontWeight: "bold",
},
attributes: {
innerText: `${label}:`,
},
}),
createElement("span", {
attributes: {
innerText: content,
},
}),
],
});
}
}
function insertBeatmapDescriptionExpander() {
if (document.querySelector(".description-expander") != null) {
return;
}
insertBeatmapDescriptionHoverButton("eye", "View full description", "description-expander", () => {
const modal = new OsuModal(new OsuModalHeader("Full Description"));
const modalWrapper = new ModalWrapper(modal);
modal.style.backgroundColor = "hsl(var(--hsl-b5))";
modal.addContent(getBeatmapDescription());
document.body.append(modalWrapper);
}, ensuredSelector(".beatmapset-info__box"));
}
function getBeatmapDescription() {
const desc = ensuredSelector(".beatmapset-info__box .bbcode");
desc.querySelectorAll(".bbcode-spoilerbox:not(.js-spoilerbox--open)").forEach((e) => {
const closedButton = e.querySelector(".bbcode-spoilerbox__link");
if (closedButton) {
closedButton.click();
}
});
insertStyleTag(`
.enhanced-modal .bbcode{
max-width: 60vw;
max-height: 80vh;
overflow-y: auto;
padding: 0px 8px;
}
`);
return desc.cloneNode(true);
}
function insertBeatmapInfoHeaderButton(icon, tooltip, className, eventHandler) {
if (document.querySelector(`.${className}`) != null) {
return;
}
const moreButton = document.querySelector(".beatmapset-header__more");
const container = ensuredSelector(".beatmapset-header__buttons");
const button = createElement("button", {
className: `${className} btn-osu-big btn-osu-big--beatmapset-header-square`,
children: [
createElement("span", {
className: "btn-osu-big__content btn-osu-big__content--center",
children: [
createElement("span", {
className: "btn-osu-big__icon",
children: [
createElement("span", {
className: "fa fa-fw",
children: [
createElement("span", {
className: `fa fa-${icon}`,
}),
],
}),
],
}),
],
}),
],
attributes: {
onclick: () => eventHandler(),
title: tooltip,
},
});
moreButton != null ? moreButton.before(button) : container.append(button);
}
function insertBeatmapDescriptionHoverButton(icon, tooltip, className, eventHandler, target) {
insertStyleTag(`
.beatmapset-info__edit-button ~ .beatmapset-info__edit-button {
right: 34px;
}
.beatmapset-info__edit-button ~ .beatmapset-info__edit-button ~ .beatmapset-info__edit-button {
right: calc(2* 34px);
}
.beatmapset-info__edit-button ~ .beatmapset-info__edit-button ~ .beatmapset-info__edit-button ~ .beatmapset-info__edit-button {
right: calc(3* 34px);
}
.beatmapset-info__edit-button ~ .beatmapset-info__edit-button ~ .beatmapset-info__edit-button ~ .beatmapset-info__edit-button ~ .beatmapset-info__edit-button {
right: calc(4* 34px);
}
`);
target.append(createElement("div", {
className: "beatmapset-info__edit-button",
children: [
createElement("button", {
className: `btn-circle ${className}`,
children: [
createElement("span", {
className: "btn-circle__content",
children: [
createElement("span", {
className: `fas fa-${icon}`,
}),
],
}),
],
attributes: {
title: tooltip,
onclick: () => eventHandler(),
},
}),
],
}));
}
/**
* Formats a customMessage with its parameters
* @param customMessage selected message @see CustomMessage
* @returns formatted message as a string
*/
function insertParametersIntoCustomMessage(customMessage) {
let chatMessage = customMessage.messageOptions.message;
if (customMessage.messageOptions.parameters !== undefined) {
customMessage.messageOptions.parameters.forEach((p) => {
const value = window.prompt(`Please insert:\n${p.label}`);
if (value == null) {
throw new Error("Input Cancelled");
}
else if (value == "") {
window.alert("Empty input: please enter something!");
throw new Error("Input Empty");
}
else {
chatMessage = chatMessage.replace(p.parameter, value);
}
});
}
return chatMessage;
}
/**
* Gets the created parameters from the supplied modal.
* @param modal modal
* @returns parameters as Parameter[]
*/
function getParametersFromInputs(modal) {
return [...Array.from(modal.querySelectorAll(".parameter-input")).map((p) => p.getParameter())];
}
/**
* Checks that all input fields are filled out
* @param modal modal to check for valid inputs
* @returns true if all are filled, false if not
*/
function checkParameterInputState(modal) {
return Array.from(modal.querySelectorAll(".parameter-input"))
.map((p) => p.checkInputState())
.every((e) => e == true);
}
/**
* This object defines an element of the message selection dropdown.
*/
class MessageSelectOption extends HTMLOptionElement {
/**
* Constructor, assign a CustomMessage to this object.
* @param customMessage The CustomMessage to be hold by this object.
*/
constructor(customMessage) {
super();
this.innerText = customMessage.messageOptions.title;
this.customMessage = customMessage;
customMessage.messageOptions.selected === true ? (this.selected = true) : (this.selected = false);
}
/**
* @returns the full CustomMessage object for this message.
*/
getCustomMessage() {
return this.customMessage;
}
}
/**
* This object defines a dropdown selector input.
*/
class OsuInputDropdown extends HTMLLabelElement {
/**
* constructor
*/
constructor() {
super();
this.className = "form-select";
this.style.backgroundColor = osuTheme.secondaryBackground;
this.selector = createElement("select", {
className: "form-select__input",
style: {
minWidth: `calc(${osuTheme.inputMinWidth} - 20px)`,
padding: "4px 24px 4px 4px",
lineHeight: "19px",
fontSize: "14px",
color: osuTheme.normalTextColor,
backgroundColor: osuTheme.secondaryBackground,
borderRadius: "4px",
},
});
this.append(this.selector);
}
/**
* This function returns the HTMLSelectElement of this component
* @returns HTMLSelectElement
*/
getSelector() {
return this.selector;
}
/**
* This function appends a HTMLOptionElement to this components selector
* @param option HTMLOptionElement to append
*/
appendOption(option) {
this.selector.append(option);
}
}
/**
* This functions adds additional BBCode shortcut buttons to the text editor.
* TODO: make it work with all editors (no clue why it doesnt currently)
*/
function insertAdditionalBBCodeButtons() {
return __awaiter(this, void 0, void 0, function* () {
try {
yield waitForElement(".post-box-toolbar .bbcode-size-select");
}
catch (e) {
return;
}
document.querySelectorAll(".bbcode-editor__buttons-bar").forEach((editor) => {
const target = ensuredSelector(".post-box-toolbar .bbcode-size-select");
if (editor.querySelector(".btn-circle--bbcode.rr-lib") == null) {
Object.entries(additionalBBCode).map((option) => {
target.before(createBBCodeButton(option[0], option[1]));
});
}
});
});
}
/**
* This function creates a BBCode shortcut button based on given input.
* @param name Name of the button/tag
* @param icon Icon to be used (using FA icons)
* @returns The button - HTMLButtonElement
*/
function createBBCodeButton(name, icon) {
const button = createElement("button", {
className: `btn-circle btn-circle--bbcode js-bbcode-btn--${name}`,
attributes: {
type: "button",
title: name.charAt(0).toUpperCase() + name.slice(1),
},
children: [
createElement("span", {
className: `btn-circle__content`,
children: [
createElement("i", {
className: icon,
}),
],
}),
],
});
let listener = () => {
insertBBCodeTagIntoTextArea(name);
};
if (name == "color") {
// @ts-ignore
const colorPicker = createElement("input", {
className: "color-picker",
attributes: {
type: "color",
value: "#FF0000",
onchange: () => insertBBCodeTagIntoTextArea(name, colorPicker.value),
},
style: {
display: "none",
},
});
document.body.append(colorPicker);
listener = () => {
colorPicker.click();
};
}
else if (name == "profile") {
listener = () => {
const userID = window.prompt("Insert user id");
if (userID) {
insertBBCodeTagIntoTextArea(name, userID);
}
};
}
button.addEventListener("click", listener);
return button;
}
/**
* This function adds the tag to the text in the editor.
* @param name Name of the tag
* @param value Optional value (used for color and profile tags)
*/
function insertBBCodeTagIntoTextArea(name, value) {
const textField = ensuredSelector(".bbcode-editor__body");
const selectionStart = textField.selectionStart;
const selectionEnd = textField.selectionEnd;
if (selectionStart == selectionEnd) {
// special coding for profile tag as it required some filler between tags to function
insertTextIntoBBCodeEditor(`[${name}${value != undefined ? `=${value}` : ""}]${name == "profile" ? "filler (do not remove this)" : ""}[/${name}]`);
}
else {
// value setting
let selectedText = textField.value.slice(selectionStart, selectionEnd);
textField.value = `${textField.value.substring(0, selectionStart)}[${name}${value != undefined ? `=${value}` : ""}]${selectedText}[/${name}]${textField.value.substring(selectionEnd, textField.value.length)}`;
textField.dispatchEvent(new Event("input", { bubbles: true }));
// sets marked area
textField.focus();
textField.selectionStart = textField.value.substring(0, selectionStart).length + `[${name}${value != undefined ? `=${value}` : ""}]`.length;
textField.selectionEnd = textField.value.substring(0, selectionEnd).length + `[${name}${value != undefined ? `=${value}` : ""}]`.length;
}
}
// Based off these:
// https://github.com/ppy/osu-web/blob/f47196f91443dbbfefe703cbf8ebc46987ab1c19/app/Libraries/BBCodeFromDB.php
// https://github.com/ppy/osu-web/blob/2d5aa6ef2e936dbea1dc34dd36899e816a64dabf/app/Libraries/BBCodeForDB.php
/**
* This class converts HTML to BBCode
*/
class BBCodeConverter {
/**
* constructor
* @param text string representation of the document
*/
constructor(text) {
this.text = text;
this.htmlDoc = new DOMParser().parseFromString(text, "text/html");
}
/**
* This function starts the parsing process.
* It starts off with a text representation of the selected HTML.
* This HTML gets converted into BBCode step by step and eventually returned
* @returns bbcode string of the selection
*/
parse() {
// end goal bbcode representation, gets started as raw html and slowly converts to bbcode
let bbCodeString = this.text;
// limit to just forum post in case selection is bigger
let bbcodeBlock = this.htmlDoc.querySelector("div.bbcode");
if (bbcodeBlock) {
bbCodeString = bbcodeBlock.innerHTML;
}
bbCodeString = this.parseAudio(bbCodeString);
bbCodeString = this.parseBold(bbCodeString);
bbCodeString = this.parseBox(bbCodeString);
bbCodeString = this.parseCentre(bbCodeString);
bbCodeString = this.parseCode(bbCodeString);
bbCodeString = this.parseColor(bbCodeString);
bbCodeString = this.parseHeading(bbCodeString);
bbCodeString = this.parseImage(bbCodeString);
bbCodeString = this.parseItalic(bbCodeString);
bbCodeString = this.parseList(bbCodeString);
bbCodeString = this.parseNotice(bbCodeString);
bbCodeString = this.parseQuote(bbCodeString);
bbCodeString = this.parseSize(bbCodeString);
bbCodeString = this.parseSmilies(bbCodeString);
bbCodeString = this.parseSpoiler(bbCodeString);
bbCodeString = this.parseStrike(bbCodeString);
bbCodeString = this.parseUnderline(bbCodeString);
bbCodeString = this.parseUrl(bbCodeString);
bbCodeString = this.parseYoutube(bbCodeString);
bbCodeString = bbCodeString.replaceAll("<br>", "\n");
bbCodeString = bbCodeString.replaceAll("<br />", "\n");
return bbCodeString;
}
parseAudio(text) {
// TODO: Use raw post content to get actual source URL instead of the proxied i.ppy.sh source (should be possible by using the indicies in the post)
let audioBlocks = this.htmlDoc.querySelectorAll("div.audio-player");
Array.from(audioBlocks).forEach((audioBlock) => {
let audioSource = audioBlock.dataset.audioUrl;
text = text.replace(audioBlock.outerHTML, `[audio]${audioSource}[/audio]`);
});
// Audio blocks are really hard to properly select, so we'll just remove any partial or invalid selections
audioBlocks = this.htmlDoc.querySelectorAll("div[class*='audio']");
Array.from(audioBlocks).forEach((audioBlock) => {
text = text.replace(audioBlock.outerHTML, "");
});
return text;
}
parseBold(text) {
text = text.replaceAll("<strong></strong>", "");
text = text.replaceAll("<strong>", "[b]");
text = text.replaceAll("</strong>", "[/b]");
return text;
}
parseBox(text) {
// TODO: This doesn't work properly when selecting a closed box + other content
// TODO: this doesn't always get the title
let boxes = this.htmlDoc.querySelectorAll("div.bbcode-spoilerbox");
Array.from(boxes).forEach((box) => {
var _a;
let boxTitle = (_a = box.querySelector("button.bbcode-spoilerbox__link")) === null || _a === void 0 ? void 0 : _a.innerText;
if (boxTitle == undefined) { // prevent empty boxes showing "undefined" due to string conversion
boxTitle = "";
}
let boxContent = box.querySelector("div.bbcode-spoilerbox__body");
if (boxContent) {
text = text.replace(box.outerHTML, `[box=${boxTitle}]${boxContent.innerHTML}[/box]`);
}
else {
text = text.replace(box.outerHTML, `[box=${boxTitle}][/box]`);
}
});
// Check for any remaining spoilerbox link tags that were missing parent elements
boxes = this.htmlDoc.querySelectorAll("button.bbcode-spoilerbox__link");
Array.from(boxes).forEach((box) => {
let boxTitle = box.innerText;
let boxContent = box.nextSibling;
if (boxContent) {
text = text.replace(box.outerHTML, `[box=${boxTitle}]${boxContent === null || boxContent === void 0 ? void 0 : boxContent.innerHTML}[/box]`);
text = text.replace(boxContent.outerHTML, ""); // Remove box content since it was included in the previous replacement
}
else {
text = text.replace(box.outerHTML, `[box=${boxTitle}][/box]`);
}
});
// Check if any box icons were selected
let boxIcons = this.htmlDoc.querySelectorAll("span.bbcode-spoilerbox__link-icon");
Array.from(boxIcons).forEach((boxIcon) => {
// Just remove them for now, if only the icon is selected then it is missing content
text = text.replace(boxIcon.outerHTML, "");
});
text = text.replaceAll("[box=][/box]", "");
return text;
}
parseCentre(text) {
text = text.replaceAll("<center></center>", "");
text = text.replaceAll("<center>", "[centre]");
text = text.replaceAll("</center>", "[/centre]");
return text;
}
parseCode(text) {
let codeBlocks = this.htmlDoc.getElementsByTagName("pre");
Array.from(codeBlocks).forEach((codeBlock) => {
text = text.replace(codeBlock.outerHTML, `[code]${codeBlock.innerText}[/code]`);
});
return text;
}
parseColor(text) {
// Get all spans with the inline style "color"
let colorElements = this.htmlDoc.querySelectorAll("span[style*='color:']");
Array.from(colorElements).forEach((colorElement) => {
var _a;
let color = (_a = colorElement.getAttribute("style")) === null || _a === void 0 ? void 0 : _a.replace("color:", "").replace(";", "");
text = text.replace(colorElement.outerHTML, `[color=${color}]${colorElement.innerHTML}[/color]`);
});
return text;
}
parseHeading(text) {
text = text.replaceAll("<h2></h2>", "");
text = text.replaceAll("<h2>", "[heading]");
text = text.replaceAll("</h2>", "[/heading]");
return text;
}
parseItalic(text) {
text = text.replaceAll("<em></em>", "");
text = text.replaceAll("<em>", "[i]");
text = text.replaceAll("</em>", "[/i]");
return text;
}
parseImage(text) {
// TODO: Use raw post content to get actual source URL instead of the proxied i.ppy.sh source (should be possible by using the indicies in the post)
// TODO: seems a bit confused with the image gallery triggering the popup
let images = this.htmlDoc.querySelectorAll("span.proportional-container");
if (images.length == 0) {
images = this.htmlDoc.querySelectorAll("span.proportional-container__height");
}
Array.from(images).forEach((image) => {
let imageChild = image.querySelector("img");
let imageUrl = imageChild === null || imageChild === void 0 ? void 0 : imageChild.src;
if (imageUrl) {
text = text.replace(image.outerHTML, `[img]${imageUrl}[/img]`);
}
else {
text = text.replace(image.outerHTML, "");
}
});
return text;
}
parseList(text) {
text = text.replaceAll("<li></li>", "");
if (text.startsWith("<li>")) {
text = '<ol class="unordered">' + text + "</ol>";
let parser = new DOMParser();
this.htmlDoc = parser.parseFromString(text, "text/html");
}
let unorderedList = this.htmlDoc.getElementsByTagName("ol");
Array.from(unorderedList).forEach((ol) => {
let bbCodeString = "[list=1]\n";
if (ol.classList.contains("unordered")) {
bbCodeString = "[list]\n";
}
let listItems = ol.getElementsByTagName("li");
Array.from(listItems).forEach((li) => {
bbCodeString += `[*]${li.innerHTML}`;
});
bbCodeString += "[/list]";
text = text.replace(ol.outerHTML, bbCodeString);
});
return text;
}
parseNotice(text) {
let notices = this.htmlDoc.getElementsByClassName("well");
Array.from(notices).forEach((notice) => {
text = text.replace(notice.outerHTML, `[notice]${notice.innerHTML}[/notice]`);
});
return text;
}
parseQuote(text) {
// TODO: Fix partially selected inner text not having the "written by" value.
// Fix only selecting title text not working at all
// Only the "written by" section of the quote was selected
// if (text.startsWith("<h4>")) {
// text = "<blockquote>" + text + "<blockquote>"
// let parser = new DOMParser();
// this.htmlDoc = parser.parseFromString(text, 'text/html');
// }
let quotes = this.htmlDoc.getElementsByTagName("blockquote");
Array.from(quotes).forEach((quote) => {
let originalQuote = quote.cloneNode(true);
let usernameBlock = quote.getElementsByTagName("h4");
let username = null;
if (usernameBlock && usernameBlock.length > 0) {
username = usernameBlock[0].innerText.replace(" wrote:", "");
quote.removeChild(usernameBlock[0]);
}
if (quote.innerHTML) {
let finalString = "[quote";
if (username) {
finalString += `="${username.trim()}"`;
}
finalString += "]";
finalString += quote.innerHTML;
finalString += "[/quote]";
text = text.replace(originalQuote.outerHTML, finalString);
}
else {
text = text.replace(originalQuote.outerHTML, "");
}
});
return text;
}
parseSmilies(text) {
let smilies = this.htmlDoc.getElementsByClassName("smiley");
Array.from(smilies).forEach((smiley) => {
if (smiley.tagName === "IMG") {
let imageSmiley = smiley;
text = text.replace(smiley.outerHTML, imageSmiley.alt + " ");
}
});
return text;
}
parseStrike(text) {
text = text.replaceAll("<del></del>", "");
text = text.replaceAll("<del>", "[s]");
text = text.replaceAll("</del>", "[/s]");
return text;
}
parseUnderline(text) {
text = text.replaceAll("<u></u>", "");
text = text.replaceAll("<u>", "[u]");
text = text.replaceAll("</u>", "[/u]");
return text;
}
parseSpoiler(text) {
// Get all spans with the class spoiler
let spoilerBlocks = this.htmlDoc.querySelectorAll("span.spoiler");
Array.from(spoilerBlocks).forEach((spoilerBlock) => {
text = text.replace(spoilerBlock.outerHTML, `[spoiler]${spoilerBlock.innerHTML}[/spoiler]`);
});
return text;
}
parseSize(text) {
// Get all spans starting with the class name "size-"
let sizeBlocks = this.htmlDoc.querySelectorAll("span[class^='size-']");
Array.from(sizeBlocks).forEach((sizeBlock) => {
let size = sizeBlock.className.replace("size-", "");
text = text.replace(sizeBlock.outerHTML, `[size=${size}]${sizeBlock.innerHTML}[/size]`);
});
return text;
}
// Parses URL, Profile, and Email tags in one function
// TODO: doesnt trigger if just a link is selected and no outside content
parseUrl(text) {
Array.from(this.htmlDoc.getElementsByTagName("a")).forEach((anchor) => {
// Remove all comments from within this anchor
// Remove any previous comments in the element
let previousSibling = anchor.previousSibling;
if (previousSibling && previousSibling.COMMENT_NODE) {
text = text.replace(`<!--${previousSibling.nodeValue}-->`, "");
}
// Remove any tailing comments in the element
let nextSibling = anchor.nextSibling;
if (nextSibling && nextSibling.COMMENT_NODE) {
text = text.replace(`<!--${nextSibling.nodeValue}-->`, "");
}
if (anchor.classList.contains("js-usercard")) {
// Profile
text = text.replace(anchor.outerHTML, `[profile=${anchor.dataset.userId}]${anchor.textContent}[/profile]`);
}
else if (anchor.href.startsWith("mailto:")) {
// Email
let email = anchor.href.replace("mailto:", "");
text = text.replace(anchor.outerHTML, `[email]${email}[/email]`);
}
else {
// All other URLs
text = text.replace(anchor.outerHTML, `[url=${anchor.href}]${anchor.innerText}[/url]`);
}
// remove links without text content
text = text.replaceAll(/\[url\=[^\]]*\]\[\/url\]/igm, "");
});
return text;
}
parseYoutube(text) {
let outerVideos = this.htmlDoc.getElementsByClassName("bbcode__video-box"); // If more than just the video itself is selected
let innerVideos = this.htmlDoc.getElementsByClassName("bbcode__video"); // If only the video is selected
let videos = Array.from(outerVideos).concat(Array.from(innerVideos));
videos.forEach((video) => {
let iframe = video.querySelector("iframe");
let source = iframe === null || iframe === void 0 ? void 0 : iframe.src;
source = source === null || source === void 0 ? void 0 : source.replace("https://www.youtube.com/embed/", "");
source = source === null || source === void 0 ? void 0 : source.replace("?rel=0", "");
if (source) {
text = text.replace(video.outerHTML, `[youtube]${source}[/youtube]`);
}
else {
text = text.replace(video.outerHTML, "");
}
});
return text;
}
}
/**
* this function enables quoting of forum posts by text selection
*/
function quoteSelectedText() {
if (!location.pathname.includes("forums/topics")) {
return;
}
document.querySelectorAll(".js-forum-post").forEach((forumPost) => {
const userName = ensuredSelector(".forum-post-info__row.forum-post-info__row--username", forumPost).innerText;
const postContent = ensuredSelector("div.forum-post__content.forum-post__content--main", forumPost);
if (postContent && postContent.dataset.mouseupListenerAdded == null) {
postContent.addEventListener("click", (event) => __awaiter(this, void 0, void 0, function* () {
// there can only ever be one quote popup in existance, remove all others
document.querySelectorAll("#quote-selected-text-popup").forEach((elem) => elem.remove());
const text = getSelectedBBCode(userName);
console.log(text);
if (text != null) {
createQuoteTextPopup(event.clientX, event.clientY + scrollY, text);
}
}));
postContent.setAttribute("data-mouseup-listener-added", "");
}
});
}
// Taken from https://stackoverflow.com/a/6668159 and modified heavily
function getSelectionHtml() {
let html = "";
if (typeof window.getSelection == "undefined") {
return html;
}
const sel = window.getSelection();
if (sel == null) {
return html;
}
let outerContainer = createElement("div"); // Outer container to store all of the nodes
let container = createElement("div"); // Inner container to append to
let node = sel.getRangeAt(0).commonAncestorContainer;
// Get the parent node for any text nodes
if (node.nodeName === "#text") {
node = node.parentNode;
}
// Special handling for box selections
let boxBodyNode = getBoxSelection(outerContainer, node);
let element = null;
if (node.nodeType == node.ELEMENT_NODE) {
element = node.cloneNode();
outerContainer.appendChild(element);
container = element;
// If the selection parent was a box link, append the box body
if (boxBodyNode) {
outerContainer.appendChild(boxBodyNode);
}
}
// Append all selected nodes to the new tree
for (let i = 0, len = sel.rangeCount; i < len; ++i) {
container.appendChild(sel.getRangeAt(i).cloneContents());
}
html = outerContainer.innerHTML;
return html;
}
function getBoxSelection(outerContainer, node) {
var _a, _b;
let boxBodyNode = null;
if (node.nodeName == "DIV" && node.classList.contains("bbcode-spoilerbox__body")) {
let boxLinkNode = (_a = node.previousSibling) === null || _a === void 0 ? void 0 : _a.cloneNode(true);
outerContainer.append(boxLinkNode);
}
else if (node.nodeName == "BUTTON" && node.classList.contains("bbcode-spoilerbox__link")) {
boxBodyNode = (_b = node.nextSibling) === null || _b === void 0 ? void 0 : _b.cloneNode(true);
// The body node needs to be appended after the link node...
}
return boxBodyNode;
}
/**
* get the currently selected part of the document as bbcode
* @param username username of the user that is being quoted, used for the quote bbcode tag
* @returns bbcode of document selection, null if nothing is selected
*/
function getSelectedBBCode(username) {
let text = new BBCodeConverter(getSelectionHtml()).parse();
if (text.trim() != "") {
return `[quote="${username}"]${text}[/quote]`;
}
else {
return null;
}
}
/**
* create the "Click to Quote" popup
* @param x x position
* @param y y postion
* @param bbcodeText bbcode text to insert on click
*/
function createQuoteTextPopup(x, y, bbcodeText) {
const quotePopup = createElement("div", {
className: "qtip qtip-default qtip tooltip-default qtip-pos-bc",
attributes: {
id: "quote-selected-text-popup",
},
style: {
display: "block",
opacity: "1",
position: "absolute",
zIndex: "17000",
cursor: "pointer",
pointerEvents: "initial",
top: `${y - 45}px`,
left: `${x - 50}px`,
},
children: [
createElement("div", {
className: "qtip-tip",
style: {
border: "0px !important",
display: "block",
width: "10px",
height: "8px",
lineHeight: "8px",
left: "50%",
marginLeft: "-5px",
bottom: "-8px",
},
}),
createElement("div", {
className: "qtip-content",
attributes: {
textContent: "Click to Quote",
},
}),
],
events: [
{
type: "click",
handler: () => __awaiter(this, void 0, void 0, function* () {
insertTextIntoBBCodeEditor(bbcodeText);
quotePopup.remove();
}),
},
],
});
document.body.append(quotePopup);
}
/**
* This function handles all the initialisation action for the forum section of the script.
*/
function insertForumModifications() {
return __awaiter(this, void 0, void 0, function* () {
do {
useFeature(settings.forum.highlightOwnName.storageKey, insertOwnNameHighlighter);
useFeature(settings.forum.showSendMessage.storageKey, insertSendMessageButton);
useFeature(settings.forum.showAdditionalBBCode.storageKey, insertAdditionalBBCodeButtons);
useFeature(settings.forum.insertQuoteAtCursorPosition.storageKey, insertQuoteAtCursorPosition);
useFeature(settings.forum.showMessageManager.storageKey, insertForumTemplateButton);
useFeature(settings.forum.quoteSelectedText.storageKey, quoteSelectedText);
yield wait(2500);
} while (location.pathname.includes("forums"));
});
}
function insertOwnNameHighlighter() {
var _a, _b;
if (document.querySelector("style.rr-lib") != null) {
return;
}
insertStyleTag(`
.osu-page--forum a[href="${(_a = document.querySelector(".js-current-user-avatar")) === null || _a === void 0 ? void 0 : _a.href}"],
.osu-page--forum-topic a[href="${(_b = document.querySelector(".js-current-user-avatar")) === null || _b === void 0 ? void 0 : _b.href}"] {
color: ${osuTheme.successColor} !important;
font-weight: 700 !important;
}
`);
}
/**
* This function adds a button to every forum post to directly jump to the chat with the user.
* Normally one would need to use the user context menu otherwise, which is a bit clunky.
*/
function insertSendMessageButton() {
Array.from(document.querySelectorAll(".forum-post-info")).map((forumPost) => {
var _a;
// if there already is a send message button return
if (forumPost.querySelector(".rr-lib.send-message-button") !== null) {
return;
}
try {
const userID = forumPost.querySelector(".forum-post-info__row.forum-post-info__row--username.js-usercard").href.split("https://osu.ppy.sh/users/")[1];
(_a = forumPost.querySelector(".forum-post-info__row--flag")) === null || _a === void 0 ? void 0 : _a.append(createElement("a", {
className: "send-message-button",
attributes: {
href: `https://osu.ppy.sh/home/messages/users/${userID}`,
title: "Send message",
},
style: {
marginLeft: "8px",
backgroundColor: osuTheme.modalBackground,
borderRadius: "4px",
color: osuTheme.normalTextColor,
width: "30px",
height: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 1px 3px rgba(0,0,0,.25)",
},
children: [createIcon("message")],
}));
}
catch (_b) { }
});
}
/**
* Causes quote buttons on forum posts to insert their content at the current cursor position in the
* topic reply box instead of at the end.
*/
function insertQuoteAtCursorPosition() {
document.querySelectorAll(".js-forum-post").forEach((forumPost) => {
let quoteButton = ensuredSelector("div.forum-post__actions button.js-forum-topic-reply--quote", forumPost);
if (quoteButton.dataset.clickListenerAdded == null) {
quoteButton.addEventListener("click", (event) => __awaiter(this, void 0, void 0, function* () {
event.stopImmediatePropagation();
insertTextIntoBBCodeEditor(yield (yield fetch(`https://osu.ppy.sh/community/forums/posts/${forumPost.dataset.postId}/raw?quote=1`)).text());
}));
quoteButton.setAttribute("data-click-listener-added", "");
}
});
}
/**
* Inserts text into the texteditor
* if cursor is a simple cursor => text at position
* if cursor is selection => replace selection
* @param text text to insert
* @param editor editor to use, defaults to first bbcode_editor__body
*/
function insertTextIntoBBCodeEditor(text, editor = ".bbcode-editor__body") {
const textField = ensuredSelector(editor);
const selectionEnd = textField.selectionEnd;
const selectionStart = textField.selectionStart;
const cursorPosition = textField.value.substring(0, selectionEnd).length + text.length;
// value setting
textField.value = textField.value.substring(0, selectionStart) + text + textField.value.substring(selectionEnd, textField.value.length);
// update event + set cursor focus
textField.dispatchEvent(new Event("input", { bubbles: true }));
textField.focus();
textField.selectionEnd = cursorPosition;
textField.selectionStart = cursorPosition;
}
/**
* inserts the button for forum customMessage modals
*/
function insertForumTemplateButton() {
if (!location.pathname.includes("topics") || document.querySelector(".rr-lib.forum-templates") !== null) {
return;
}
insertStyleTag(`
.forum-templates:hover{
background-color: #382e32 !important
}
`);
ensuredSelector(".bbcode-editor--reply .bbcode-editor__header").append(createElement("div", {
className: "forum-templates",
children: [createIcon("collection")],
attributes: {
title: "Forum Post Templates",
onclick: () => {
document.body.append(new ModalWrapper(createMessageSelectModal("forum")));
},
},
style: {
padding: "4px",
backgroundColor: "#46393f",
cursor: "pointer",
display: "flex",
borderRadius: "4px",
},
}));
}
/**
* inserts a customMessage into the BBCode Editor
* @param customMessage customMessage to insert
*/
function insertForumMessage(customMessage) {
insertTextIntoBBCodeEditor(insertParametersIntoCustomMessage(customMessage), "div:not(.js-forum-post) textarea.bbcode-editor__body");
}
/**
* Gets a sorted list of messages from local storage.
* @param customMessageType type of custom message @see customMessageTypes
*/
function getSortedCustomMessages(customMessageType) {
const messages = [];
getCustomMessagesKeys(customMessageType).forEach((key) => {
const message = readCustomMessage(key);
if (message != null) {
messages.push(message);
}
});
if (messages.length == 0) {
// this can not be undefined, as its set by the script,unless the user manually messes with the storage.
messages.push(readCustomMessage(`customMessage-fallback`));
}
return messages.sort((a, b) => {
return a.messageOptions.title > b.messageOptions.title ? 1 : -1;
});
}
/**
* Get storageKeys of all custom messages of given type
* @param customMessageType type of custom message @see customMessageTypes
* @returns string[] of storageKeys
*/
function getCustomMessagesKeys(customMessageType) {
const keyMatch = `${templateKeys[customMessageType]}-[0-9]+$`;
let messagesStorageKeys = [];
gmListValues().forEach((v) => {
if (v.match(keyMatch)) {
messagesStorageKeys.push(v);
}
});
return messagesStorageKeys;
}
/**
* Generates the storageKey for a customMessage
* @param customMessageType type of custom message @see customMessageTypes
* @returns storagekey for customMessage.storageKey
*/
function generateCustomMessageStorageKey(customMessageType) {
return `${templateKeys[customMessageType]}-${Date.now()}`;
}
/**
* Reads a customMessage from storage
* @param storageKey key the message is stored under
* @returns CustomMessage @see CustomMessage
*/
function readCustomMessage(storageKey) {
try {
return JSON.parse(gmGetValue(storageKey));
}
catch (e) {
if (e instanceof UndefinedStorageValueException) {
return null;
}
else {
throw e;
}
}
}
/**
* Saves a customMessage to storage
* @param customMessage customMessage to be saved
*/
function saveCustomMessage(customMessage) {
gmSetValue(customMessage.storageKey, JSON.stringify(customMessage));
}
/**
* Deletes a customMessage from storage
* @param customMessage customMessage to be deleted
*/
function deleteCustomMessage(customMessage) {
gmDeleteValue(customMessage.storageKey);
}
/**
* Text input styled for the osu website.
*/
class OsuInputText extends HTMLInputElement {
/**
* constructor
* @param placeholder optional, placeholder text
*/
constructor(placeholder = "") {
super();
this.type = "text";
this.placeholder = placeholder;
this.style.padding = "4px";
this.style.border = "none";
this.style.outline = "none";
this.style.borderRadius = "4px";
this.style.minWidth = osuTheme.inputMinWidth;
this.style.backgroundColor = osuTheme.secondaryBackground;
this.style.color = osuTheme.normalTextColor;
this.style.fontSize = "14px";
this.style.lineHeight = "19px";
}
}
/**
* This object defines the character counter for the custom message editor.
*/
class MessageEditorCharacterCount extends HTMLDivElement {
/**
* constructor
* @param count starting count
*/
constructor(count) {
super();
/**
* This number defines the maximum length of a message, defined by the chat itself.
*/
this.maxMessageLength = 449;
this.counter = createElement("span", { attributes: { innerText: count.toString() } });
const counterRight = createElement("span", { attributes: { innerText: `/${this.maxMessageLength.toString()}` } });
this.style.color = osuTheme.normalTextColor;
this.style.fontSize = "14px";
this.style.minWidth = "52px";
this.append(this.counter, counterRight);
}
/**
* This method updates the character counter to the given number. If the number exceed the maximum message length the counter will be turned red.
* @param count length of the custom message.
*/
updateCounter(count) {
this.counter.innerText = count.toString();
if (count > this.maxMessageLength) {
this.style.color = osuTheme.failureColor;
}
else {
this.style.color = osuTheme.normalTextColor;
}
}
}
/**
* This object defines an element of the message editor for a parameter input pair.
*/
class CustomMessageParameterInput extends HTMLDivElement {
/**
* constructor
* @param notificationArea The notification area to display notifications in
* @param editorActionType The type of editor action @see editorActionType
* @param parameter The parameter, defaults to empty parameter
*/
constructor(notificationArea, editorActionType, parameter = { label: "", parameter: "" }) {
super();
this.parameter = parameter;
this.style.display = "flex";
this.style.alignItems = "center";
this.className = "parameter-input";
this.nameInput = new OsuInputText("please enter variable name");
this.nameInput.value = parameter.label;
this.nameInput.addEventListener("input", () => {
notificationArea.updateMessage(editorActionType == "new" ? "danger" : "normal", editorActionType == "new" ? "Unsaved Changes" : "Everything allright");
this.parameter.label = this.nameInput.value;
});
this.variableInput = new OsuInputText("please enter variable identifier");
this.variableInput.value = parameter.parameter;
this.variableInput.addEventListener("input", () => {
notificationArea.updateMessage(editorActionType == "new" ? "danger" : "normal", editorActionType == "new" ? "Unsaved Changes" : "Everything allright");
this.parameter.parameter = this.variableInput.value;
});
const deleteButton = createElement("div", {
attributes: {
title: "Delete Variable",
onclick: () => {
notificationArea.updateMessage(editorActionType == "new" ? "danger" : "normal", editorActionType == "new" ? "Unsaved Changes" : "Everything allright");
this.remove();
},
},
style: {
padding: "4px",
cursor: "pointer",
},
children: [
createIcon("trash", {
size: 20,
}),
],
});
this.append(new OsuInputLabel("name", this.nameInput, "Name"), new OsuInputLabel("identifier", this.variableInput, "Variable"), deleteButton);
}
/**
* This function checks if all inputs in this parameter input have been filled out
* @returns if all are filled => true, else => false
*/
checkInputState() {
return this.nameInput.value == "" || this.variableInput.value == "" ? false : true;
}
/**
* This function returns the parameter this object reflects
* @returns
*/
getParameter() {
return this.parameter;
}
}
/**
* This object defines the notification area of the custom chat message editor.
* It can have various different status. @see notificationType
*/
class ModalNotificationArea extends HTMLDivElement {
/**
* constructor
* @param startingType defines the notificationType for the initial creation
* @param message defines the message for the initial creation
*/
constructor(startingType, message) {
super();
this.innerText = message;
if (startingType === "danger") {
this.style.color = osuTheme.failureColor;
}
else if (startingType === "normal") {
this.style.color = osuTheme.normalTextColor;
}
this.style.width = "100%";
this.style.padding = "4px";
this.style.backgroundColor = osuTheme.secondaryBackground;
this.style.borderRadius = "4px";
this.style.fontSize = "14px";
}
/**
* This method updates the notification and status type.
* @param type the type of the status update. @see notificationType
* @param message the message the status should display.
*/
updateMessage(type, message) {
this.innerText = message;
if (type === "danger") {
this.style.color = osuTheme.failureColor;
}
else if (type === "normal") {
this.style.color = osuTheme.normalTextColor;
}
}
}
/**
* This function creates the edit view for custom messages
* @param modalWrapper modalwrapper to be inserted into
* @param customMessage customMessage to edit, @see CustomMessage
* @param actionType type of edit action, @see editorActionType
* @returns modal
*/
function createMessageEditor(customMessage, actionType, customMessageType) {
const modalHeader = new OsuModalHeader(actionType == "new" ? "New Message" : "Edit Message");
const newMessageModal = new OsuModal(modalHeader);
newMessageModal.style.minWidth = "700px";
// create top information
const notificationArea = new ModalNotificationArea(actionType == "new" ? "danger" : "normal", actionType == "new" ? "Unsaved Changes" : "Everything alright");
const information = createElement("div", {
style: {
display: "flex",
alignItems: "center",
},
children: [notificationArea],
});
// always create counter to have access in the event listnerers.
const characterCounter = new MessageEditorCharacterCount(customMessage.messageOptions.message.length);
characterCounter.style.marginLeft = "8px";
if (customMessageType == "chat") {
information.prepend(characterCounter);
}
// create title input
const container = createElement("div", {
style: {
display: "flex",
alignItems: "center",
},
});
const titleInput = new OsuInputText("please insert message name");
titleInput.style.width = "100%";
titleInput.value = actionType == "new" ? "" : customMessage.messageOptions.title;
titleInput.addEventListener("input", () => {
customMessage.messageOptions.title = titleInput.value;
notificationArea.updateMessage("danger", "Unsaved Changes");
});
container.append(createElement("span", {
attributes: {
innerText: "Name",
},
style: {
fontSize: "14px",
marginLeft: "8px",
minWidth: "52px",
},
}), titleInput);
// create message write area
const messageArea = createTextAreaInput("textarea", actionType == "new" ? "" : customMessage.messageOptions.message);
messageArea.rows = 7;
messageArea.addEventListener("input", () => {
customMessage.messageOptions.message = messageArea.value;
if (customMessageType == "chat") {
characterCounter.updateCounter(messageArea.value.length);
}
notificationArea.updateMessage("danger", "Unsaved Changes");
});
// append items
newMessageModal.addContent(container);
newMessageModal.addContent(information);
newMessageModal.addContent(messageArea);
// append parameter inputs
customMessage.messageOptions.parameters.forEach((p) => {
messageArea.after(new CustomMessageParameterInput(notificationArea, "edit", p));
});
// add buttons
newMessageModal.addModalButton(new OsuModalButton("Back", () => {
newMessageModal.replaceWith(createMessagesManager(customMessageType));
}, "secondary", "back"));
newMessageModal.addModalButton(new OsuModalButton("Save", () => {
if (customMessage.messageOptions.message !== "" && customMessage.messageOptions.title !== "" && checkParameterInputState(newMessageModal)) {
customMessage.messageOptions.parameters = getParametersFromInputs(newMessageModal);
saveCustomMessage(customMessage);
notificationArea.updateMessage("normal", "Everything Alright");
}
else {
notificationArea.updateMessage("danger", "Please fill out all fields!");
}
}, "primary", "save"));
newMessageModal.addModalButton(new OsuModalButton("New Variable", () => {
messageArea.after(new CustomMessageParameterInput(notificationArea, "new"));
}, "primary", "new-variable"));
return newMessageModal;
}
/**
* This function create the modal for exporting a message.
* @param modalWrapper modalwrapper to be inserted into
* @param customMessage customMessage to export, @see CustomMessage
* @param customMessageType
* @returns modal
*/
function createMessageExport(customMessage, customMessageType) {
customMessage.storageKey = generateCustomMessageStorageKey(customMessageType);
const modal = new OsuModal(new OsuModalHeader("Export Message"));
const notificationArea = new ModalNotificationArea("danger", "Do not change any of this unless you know what you are doing!");
const textArea = createTextAreaInput("textarea", JSON.stringify(customMessage));
modal.addModalButton(new OsuModalButton("Back", () => {
modal.replaceWith(createMessagesManager(customMessageType));
}, "secondary", "back"));
modal.addModalButton(new OsuModalButton("Copy", () => __awaiter(this, void 0, void 0, function* () {
yield navigator.clipboard.writeText(textArea.value);
notificationArea.updateMessage("normal", "Copied");
yield wait(3000);
notificationArea.updateMessage("danger", "Do not change any of this unless you know what you are doing!");
}), "primary", "copy"));
modal.addContent(textArea);
modal.addContent(notificationArea);
return modal;
}
/**
* One entry in the message editors message list
*/
class MessageEditorListItem extends HTMLDivElement {
/**
* constructor
* @param customMessage the customMessage to display in the modal
* @param previousModal previous modal for replacement purposes
*/
constructor(customMessage, previousModal, customMessageType) {
super();
this.customMessage = customMessage;
this.modal = previousModal;
this.customMessageType = customMessageType;
this.style.display = "flex";
this.style.justifyContent = "space-between";
this.style.alignItems = "center";
this.append(createElement("span", {
style: {
fontSize: "14px",
marginLeft: "8px",
},
attributes: {
innerText: this.customMessage.messageOptions.title,
},
}), createElement("div", {
style: {
display: "flex",
},
children: [
this.createIconButton("upload", "Export Message", () => {
this.exportListener();
}),
this.createIconButton("edit", "Edit Message", () => {
this.editListener();
}),
this.createIconButton("trash", "Delete Message", () => {
this.deleteListener();
}),
],
}));
}
/**
* Creates each icon button for this list item
* @param icon icon to be used, @see IconType
* @param tooltip tooltip
* @param eventFunction function for "click" EventListener
* @returns the button
*/
createIconButton(icon, tooltip, eventFunction) {
const button = createIcon(icon);
button.style.marginLeft = "4px";
button.style.cursor = "pointer";
button.addEventListener("click", () => {
eventFunction();
});
const container = createElement("div", {
style: {
display: "flex",
},
attributes: {
title: tooltip,
},
children: [button],
});
return container;
}
/**
* listener for delete button
*/
deleteListener() {
if (window.confirm(`Are you sure you want to delete ${this.customMessage.messageOptions.title}?`)) {
deleteCustomMessage(this.customMessage);
this.modal.replaceWith(createMessagesManager(this.customMessageType));
}
}
/**
* listener for edit button
*/
editListener() {
this.modal.replaceWith(createMessageEditor(this.customMessage, "edit", this.customMessageType));
}
/**
* listener for export button
*/
exportListener() {
this.modal.replaceWith(createMessageExport(this.customMessage, this.customMessageType));
}
}
/**
* This function create the modal for importing a message.
* @param modalWrapper modalwrapper to be inserted into
* @param customMessageType
* @returns modal
*/
function createMessageImport(customMessageType) {
const modal = new OsuModal(new OsuModalHeader("Import Message"));
const notificationArea = new ModalNotificationArea("normal", "Please enter the message.");
const textArea = createTextAreaInput("textarea", "");
modal.addModalButton(new OsuModalButton("Back", () => {
modal.replaceWith(createMessagesManager(customMessageType));
}, "secondary", "back"));
modal.addModalButton(new OsuModalButton("Save Message", () => __awaiter(this, void 0, void 0, function* () {
// save
saveCustomMessage(JSON.parse(`${textArea.value}`));
notificationArea.updateMessage("normal", "Saved!");
// reset
textArea.value = "";
yield wait(3000);
notificationArea.updateMessage("normal", "Please enter the message.");
}), "primary", "save"));
modal.addContent(textArea);
modal.addContent(notificationArea);
return modal;
}
/**
* This function creates the managment view for custom messages
* @param modalWrapper modalwrapper to be inserted into
* @param customMessageType
* @returns modal
*/
function createMessagesManager(customMessageType) {
const modalHeader = new OsuModalHeader("Manage Messages");
const modal = new OsuModal(modalHeader);
insertStyleTag(`
.toggle-active .toggle { right: 3px !important }
.toggle-active .toggle-bg { border-color: ${osuTheme.successColor} !important }
`);
modal.addContent(createElement("div", {
style: {
display: "grid",
gap: "8px",
},
children: [
...getSortedCustomMessages(customMessageType).map((customMessage) => {
return new MessageEditorListItem(customMessage, modal, customMessageType);
}),
],
}));
modal.addModalButton(new OsuModalButton("Back", () => {
modal.replaceWith(createMessageSelectModal(customMessageType));
}, "secondary", "back"));
modal.addModalButton(new OsuModalButton("New Message", () => {
modal.replaceWith(createMessageEditor({
messageOptions: {
title: "",
message: "",
parameters: [],
},
storageKey: generateCustomMessageStorageKey(customMessageType),
}, "new", customMessageType));
}, "primary", "new"));
modalHeader.addIconButton("download", "Import Message", () => {
modal.replaceWith(createMessageImport(customMessageType));
});
return modal;
}
/**
* This function creates the message selector modal
* @param modalWrapper
* @param customMessageType
* @return modal
*/
function createMessageSelectModal(customMessageType) {
const modalHeader = new OsuModalHeader("Select Message");
const modal = new OsuModal(modalHeader);
modalHeader.addIconButton("edit", "Manage Messages", () => {
modal.replaceWith(createMessagesManager(customMessageType));
});
const selector = new OsuInputDropdown();
getSortedCustomMessages(customMessageType).forEach((customMessage) => {
selector.appendOption(new MessageSelectOption(customMessage));
});
modal.addContent(selector);
switch (customMessageType) {
case "chat":
modal.addModalButton(new OsuModalButton("Send", () => {
sendChatMessage(getSelectedOption());
}, "primary", "send"));
break;
case "forum":
modal.addModalButton(new OsuModalButton("Insert", () => {
insertForumMessage(getSelectedOption());
}, "primary", "insert-template"));
break;
}
return modal;
function getSelectedOption() {
return selector.getSelector().options[selector.getSelector().selectedIndex].getCustomMessage();
}
}
/**
* Insert chat manager button
*/
function chatShortcuts() {
return __awaiter(this, void 0, void 0, function* () {
const target = yield waitForElement(".chat-input");
target.style.paddingLeft = "30px";
do {
useFeature(settings.other.showMessageManager.storageKey, () => {
if (document.querySelector(".rr-lib.btn-osu-big.btn-osu-big--chat-templates") !== null) {
return;
}
target.prepend(createChatTemplatesButton());
});
yield wait(2000);
} while (location.pathname.includes("chat"));
});
}
/**
* Creates chat manager button
* @returns button
*/
function createChatTemplatesButton() {
return createElement("button", {
className: "btn-osu-big btn-osu-big--chat-templates",
style: {
marginRight: "10px",
borderRadius: "10000px",
padding: "9px",
},
children: [
createElement("span", {
className: "btn-osu-big__content",
children: [
createElement("span", {
className: "btn-osu-big__icon",
children: [createIcon("collection", { size: 20, display: "flex", alignItems: "center" })],
}),
],
}),
],
attributes: {
onclick: () => {
document.body.append(new ModalWrapper(createMessageSelectModal("chat")));
},
},
});
}
/**
* This function sends a message in the currently selected chat.
* @param message The message to be sent in the chat.
*/
function sendChatMessage(customMessage) {
const chatInput = ensuredSelector(".chat-input__box");
const formattedInput = insertParametersIntoCustomMessage(customMessage);
if (window.confirm(`Are you sure you want to send this message to ${getSelectedChatUser()}`)) {
// ! is bad, but I have no clue how to fix it here, since this code is based on some old code.
const nativeIn = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value").set;
nativeIn.call(chatInput, formattedInput);
chatInput.dispatchEvent(new Event("input", { bubbles: true }));
ensuredSelector(".btn-osu-big.btn-osu-big--chat-send").click();
ensuredSelector(".chat-input__box").disabled = false;
}
}
/**
* This function returns the currently selected userchat.
* @returns The currently selected user.
*/
function getSelectedChatUser() {
return ensuredSelector(".chat-conversation-list-item.chat-conversation-list-item--selected .chat-conversation-list-item__name").innerText;
}
/**
* This component defines the me! section expander
*/
class UserProfileMeExpander extends HTMLDivElement {
/**
* constructor
*/
constructor() {
super();
this.classList.add("me-expander");
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.alignItems = "center";
this.style.cursor = "pointer";
this.style.borderTop = `1px solid ${osuTheme.osuPink}`;
this.style.position = "absolute";
this.style.marginLeft = "-50px";
this.style.userSelect = "none";
this.style.right = "0";
this.style.bottom = "0";
this.style.width = "100%";
this.style.fontWeight = "700";
this.style.backgroundColor = "inherit";
this.label = createElement("span", {
attributes: {
innerText: "Expand",
},
});
this.state = "expand";
this.onclick = () => {
this.toggleState();
};
insertStyleTag(`
.me-expander:hover {
filter: brightness(1.2)
}
.me-expander.expanded svg {
transform: rotate(180deg)
}
`);
this.append(this.label, createIcon("chevronDown"));
insertStyleTag(`
.me-expander:hover {
background-color: ${osuTheme.secondaryBackground}
}
.me-expander.expanded svg {
transform: rotate(180deg)
}
`);
}
/**
* This function toggles the state of the expander. This includees:
* - this.state
* - expand/foldin userpage
* - turn chevron
* - update label
* @param state
*/
toggleState(state = this.state) {
if (state == "expand") {
try {
this.expandMeSection();
}
catch (_a) { }
this.updateLabel("Collapse");
this.state = "collapse";
this.classList.add("expanded");
}
else if (state == "collapse") {
try {
this.foldInMeSection();
}
catch (_b) { }
this.updateLabel("Expand");
this.state = "expand";
this.classList.remove("expanded");
}
}
/**
* This function updates the label of the expander
* @param label
*/
updateLabel(label) {
this.label.innerText = label;
}
/**
* This function expands the userpage
*/
expandMeSection() {
const mainSection = ensuredSelector(".page-extra__content-overflow-wrapper-inner");
mainSection.style.maxHeight = "unset";
mainSection.style.overflowY = "scroll";
ensuredSelector(".page-extra__content-overflow-wrapper-outer").style.maxHeight = "unset";
}
/**
* This function folds in the userpage
*/
foldInMeSection() {
ensuredSelector(".page-extra__content-overflow-wrapper-inner").style.maxHeight = "400px";
ensuredSelector(".page-extra__content-overflow-wrapper-outer").style.maxHeight = "400px";
}
}
function hideNotificationsCount() {
let previousTitle = "";
const observer = new MutationObserver(() => {
if (document.title !== previousTitle) {
let newTitle = document.title;
if (document.title.match(/^\(\d*\) .*/)) {
newTitle = newTitle.replace(/^\(\d*\) /, "");
}
document.title = newTitle;
previousTitle = newTitle;
}
});
observer.observe(document, { subtree: true, childList: false, attributes: true, characterData: false });
}
/**
* This functions intitialises all the userpage related modifications.
*/
function insertUserpageModifications() {
return __awaiter(this, void 0, void 0, function* () {
yield waitForElement(".page-extra--userpage");
do {
useFeature(settings.profile.showExpandMe.storageKey, insertMeSectionExpander);
useFeature(settings.profile.convertRankToLink.storageKey, convertRankToLink);
yield wait(2000);
} while (location.pathname.split("/")[1] === "users");
});
}
function getUserInfo() {
return __awaiter(this, void 0, void 0, function* () {
// gets user data from data-initial-data
// any user pages within the array are unsupported
if (["realtime", "playlists", "modding"].some((e) => window.location.pathname.includes(e))) {
throw new Error("unsupported path");
}
else {
return JSON.parse((yield waitForElement(".js-react--profile-page")).dataset.initialData);
}
});
}
/**
* inserts the me! section expander
* @returns
*/
function insertMeSectionExpander() {
if (document.querySelector(".me-expander") != null) {
return;
}
const userpage = ensuredSelector(".page-extra--userpage");
userpage.style.paddingBottom = "35px";
userpage.append(new UserProfileMeExpander());
// add eventListener to edit button to reset expander state
const button = ensuredSelector(".page-extra--userpage .btn-circle--page-toggle");
if (button.classList.contains("edited")) {
return;
}
button.addEventListener("click", () => {
var _a;
(_a = document.querySelector(".expander")) === null || _a === void 0 ? void 0 : _a.toggleState("collapse");
});
button.classList.add("edited");
}
/**
* converts the rank on user profiles to links to the respective (country) ranking page
* if the rank is over 10000 it is skipped, as only the first 200 pages of 50 results each are shown
*/
function convertRankToLink() {
return __awaiter(this, void 0, void 0, function* () {
if (["realtime", "playlists", "modding"].some((e) => window.location.pathname.includes(e))) {
return; // modding, playlists and multiplayer pages do not contain ranks
}
const rankElements = document.querySelectorAll(".profile-detail__chart-numbers .value-display--rank .value-display__value div:not(.rank-to-link-checked)");
rankElements.forEach((e) => e.classList.add("rank-to-link-checked")); // adds a class in case the rank is outside the conversion scope
if (rankElements.length != 2) {
// else this runs in an endless loop as it can no longer find the original elements
return;
}
const userInfo = yield getUserInfo();
const globalRankElement = rankElements[0];
const countryRankElement = rankElements[1];
let gamemode = userInfo.user.playmode;
if (window.location.pathname.match(/\/users\/[0-9]*\/(fruits|osu|taiko|mania)/gm)) {
gamemode = window.location.pathname.split("/")[3];
}
const globalRank = userInfo.user.statistics.global_rank;
const countryRank = userInfo.user.statistics.country_rank;
if (globalRank != null && globalRank < 200 * 50) {
const newGlobalRankElement = createElement("a", {
attributes: {
href: `https://osu.ppy.sh/rankings/${gamemode}/performance?page=${Math.floor(globalRank / 50) + 1}#scores`,
title: "",
innerText: globalRankElement.innerText,
},
});
newGlobalRankElement.setAttribute("data-html-title", globalRankElement.dataset.htmlTitle);
globalRankElement.replaceWith(newGlobalRankElement);
}
if (countryRank != null && countryRank < 200 * 50) {
const newCountryRankElement = createElement("a", {
attributes: {
href: `https://osu.ppy.sh/rankings/${gamemode}/performance?page=${Math.floor(countryRank / 50) + 1}&country=${userInfo.user.country_code}#scores`,
title: "",
innerText: countryRankElement.innerText,
},
});
newCountryRankElement.setAttribute("data-html-title", countryRankElement.dataset.htmlTitle);
countryRankElement.replaceWith(newCountryRankElement);
}
});
}
// ==UserScript==
// @name osu-web enhanced
// @version 1.3.2
// @author RockRoller
// @match https://osu.ppy.sh/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @downloadURL https://gist.github.com/RockRoller01/e0e10ff9e5716701e4a0b54f6bcddf42/raw/script.user.js
// @updateURL https://gist.github.com/RockRoller01/e0e10ff9e5716701e4a0b54f6bcddf42/raw/script.user.js
// ==/UserScript==
const routes = [
{
match: ["forums"],
render: () => insertForumModifications(),
},
{
match: ["beatmapsets"],
render: () => insertBeatmapModifications(),
},
{
match: ["chat"],
render: () => chatShortcuts(),
},
{
match: ["users"],
render: () => insertUserpageModifications(),
},
];
function determineOsuRoute() {
for (const route of routes) {
const splitURL = location.pathname.split("/");
const matches = splitURL.some((u) => route.match.includes(u));
if (matches) {
return route.render();
}
}
}
function main() {
return __awaiter(this, void 0, void 0, function* () {
// functions registered in this section will only run once per tab
initSettings();
initComponents();
useFeature(settings.other.hideNotificationCount.storageKey, hideNotificationsCount);
let previousUrl = "";
const observer = new MutationObserver(() => {
if (document.URL !== previousUrl) {
previousUrl = document.URL;
handleNavigationChange();
}
});
observer.observe(document, { subtree: true, childList: false, attributes: true, characterData: false });
});
}
main();
// functions registered in this section will run on every navigation
function handleNavigationChange() {
useFeature(settings.other.logAPIdata.storageKey, logAPIData);
useFeature(settings.other.openNonPPYLinksExternal.storageKey, () => {
setInterval(() => openExternalLinksInNewTab(), 2500);
});
insertSettingsModalOpenButton();
determineOsuRoute();
}
/**
* This function registers all components, must be called before using the components
*/
function initComponents() {
customElements.define("custom-input-toggle-e", ToggleInput, { extends: "div" });
customElements.define("modal-wrapper-e", ModalWrapper, { extends: "div" });
customElements.define("base-modal-e", BaseModal, { extends: "div" });
customElements.define("base-modal-button-e", BaseModalButton, { extends: "button" });
customElements.define("base-modal-header-e", BaseModalHeader, { extends: "div" });
customElements.define("osu-modal-e", OsuModal, { extends: "div" });
customElements.define("osu-modal-button-e", OsuModalButton, { extends: "button" });
customElements.define("osu-modal-header-e", OsuModalHeader, { extends: "div" });
customElements.define("chat-character-count-e", MessageEditorCharacterCount, { extends: "div" });
customElements.define("chat-message-list-item-e", MessageEditorListItem, { extends: "div" });
customElements.define("message-manager-select-option-e", MessageSelectOption, { extends: "option" });
customElements.define("user-profile-me-expander-e", UserProfileMeExpander, { extends: "div" });
customElements.define("osu-input-text-e", OsuInputText, { extends: "input" });
customElements.define("modal-notification-area-e", ModalNotificationArea, { extends: "div" });
customElements.define("message-manager-parameter-input-e", CustomMessageParameterInput, { extends: "div" });
customElements.define("osu-input-dropdown-e", OsuInputDropdown, { extends: "label" });
customElements.define("osu-input-label-e", OsuInputLabel, { extends: "label" });
}
function logAPIData() {
document.querySelectorAll("script[id*=json]").forEach((e) => {
console.log(e.id, JSON.parse(e.innerText));
});
}
function openExternalLinksInNewTab() {
document.querySelectorAll("a").forEach((a) => {
if (a.href == "") {
return;
}
try {
const url = new URL(a.href);
if (url.protocol.includes("http") && !url.host.includes("ppy.sh")) {
a.target = "_blank";
}
}
catch (e) {
console.log(a, a.href);
}
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment