Customize and Share MWI Avatar

Allow you to replace your avatar with any image, and share it with other players who also installed this script.

// ==UserScript==
// @name         Customize and Share MWI Avatar
// @namespace    http://tampermonkey.net/
// @version      1.7
// @description  Allow you to replace your avatar with any image, and share it with other players who also installed this script.
// @author       VoltaX
// @match        https://www.milkywayidle.com/*
// @match        https://test.milkywayidle.com/*
// @connect      https://mwi-avatar.voltax.workers.dev
// @icon         http://milkywayidle.com/favicon.ico
// @grant        none
// ==/UserScript==
const css = 
`
.custom-mwi-avatar{
    width: 100%;
    height: 100%;
}
`;
const InsertStyleSheet = (style) => {
    const s = new CSSStyleSheet();
    s.replaceSync(style);
    document.adoptedStyleSheets = [...document.adoptedStyleSheets, s];
};
InsertStyleSheet(css);
const HTML = (tagname, attrs, ...children) => {
    if(attrs === undefined) return document.createTextNode(tagname);
    const ele = document.createElement(tagname);
    if(attrs) for(const [key, value] of Object.entries(attrs)){
        if(value === null || value === undefined) continue;
        if(key.charAt(0) === "_"){
            const type = key.slice(1);
            ele.addEventListener(type, value);
        }
        else if(key === "eventListener"){
            for(const listener of value){
                ele.addEventListener(listener.type, listener.listener, listener.options);
            }
        }
        else ele.setAttribute(key, value);
    }
    for(const child of children) if(child) ele.append(child);
    return ele;
};
const RemoteHost = "https://mwi-avatar.voltax.workers.dev";
const AvatarPath = "/get-avatar";
const AvatarsPath = "/get-avatars";
const UploadPath = "/set-avatar";
let PlayerUsername = "";
let avatarCache;
let lastUpdated;
const expireTime = 3 * 60 * 60 * 1000;
class Lock{
    #queue = [];
    #count = 0;
    constructor(count){
        this.#count = count;
        this.release = this.release.bind(this);
    };
    acquire(){
        if(this.#count > 0) {
            this.#count -= 1;
            return this.release;
        }
        else{
            const {promise, resolve} = Promise.withResolvers();
            this.#queue.push(resolve);
            return promise;
        }
    };
    release(){
        if(this.#queue.length > 0){
            const front = this.#queue.shift();
            front(this.release);
        }
        else this.#count += 1;
    };
};
const ReqLock = new Lock(1);
const InitCache = () => {
    try{
        avatarCache = JSON.parse(window.localStorage.getItem("custom-avatar-cache") ?? "undefined");
        lastUpdated = JSON.parse(window.localStorage.getItem("custom-avatar-cache-updated") ?? "undefined");
    } catch(e){
        avatarCache = undefined;
        lastUpdated = undefined;
    }
}
InitCache();
const SaveCache = () => {
    window.localStorage.setItem("custom-avatar-cache", JSON.stringify(avatarCache));
    window.localStorage.setItem("custom-avatar-cache-updated", JSON.stringify(lastUpdated));
};
const UpdateCache = async () => {
    const res = await fetch(`${RemoteHost}${AvatarsPath}`, {mode: "cors"});
    if(res.status === 200){
        avatarCache = await res.json();
        lastUpdated = new Date().getTime();
        SaveCache();
        return true;
    }
    else return false;
};
const CheckCache = async (username) => {
    if(!lastUpdated || !avatarCache || (new Date().getTime() - lastUpdated >= expireTime)){
        const cacheValid = await UpdateCache();
        if(cacheValid) return avatarCache[username];
        else return false;
    }
    else return avatarCache[username];
};
const GetCustomAvatar = async (username) => {
    const lock = await ReqLock.acquire();
    const result = await CheckCache(username);
    lock();
    return result;
};
const ReplaceHeaderAvatar = async () => {
    const characterInfoDiv = document.querySelector("div.Header_characterInfo__3ixY8:not([avatar-modified])");
    if(!characterInfoDiv) return;
    console.log("ReplaceHeader");
    characterInfoDiv.setAttribute("avatar-modified", "");
    const username = characterInfoDiv.querySelector(":scope div.CharacterName_name__1amXp").dataset.name;
    if(!PlayerUsername) PlayerUsername = username;
    const avatarWrapperDiv = characterInfoDiv.querySelector(":scope div.Header_avatar__2RQgo");
    const avatarURL = await GetCustomAvatar(username);
    if(avatarURL) avatarWrapperDiv.replaceChildren(
        HTML("img", {class: "custom-mwi-avatar", src: avatarURL})
    );
};
const ReplaceProfileAvatar = async () => {
    const profileDiv = document.querySelector("div.SharableProfile_modal__2OmCQ:not([avatar-modified])");
    if(!profileDiv) return;
    profileDiv.setAttribute("avatar-modified", "");
    const username = profileDiv.querySelector(":scope div.CharacterName_name__1amXp").dataset.name;
    const avatarWrapperDiv = profileDiv.querySelector(":scope div.FullAvatar_fullAvatar__3RB2h.FullAvatar_xlarge__1cmUN");
    const avatarURL = await GetCustomAvatar(username);
    if(avatarURL) avatarWrapperDiv.replaceChildren(
        HTML("img", {class: "custom-mwi-avatar", src: avatarURL})
    );
};
const ReplacePartyMember = async () => {
    const slotDiv = document.querySelector("div.Party_partySlots__3zGeH:not([avatar-modified])");
    if(!slotDiv) return;
    slotDiv.setAttribute("avatar-modified", "");
    const username = slotDiv.querySelector(":scope div.CharacterName_name__1amXp").dataset.name;
    const avatarWrapperDiv = slotDiv.querySelector(":scope div.FullAvatar_fullAvatar__3RB2h.FullAvatar_large__fJGwX");
    const avatarURL = await GetCustomAvatar(username);
    if(avatarURL) avatarWrapperDiv.replaceChildren(
        HTML("img", {class: "custom-mwi-avatar", src: avatarURL})
    );
};
const ReplaceCombatUnit = async () => {
    const unitDiv = document.querySelector("div.CombatUnit_combatUnit__1m3XT:not([avatar-modified])");
    if(!unitDiv) return;
    unitDiv.setAttribute("avatar-modified", "");
    const username = unitDiv.querySelector(":scope div.CombatUnit_name__1SlO1").textContent;
    const avatarWrapperDiv = unitDiv.querySelector(":scope div.FullAvatar_fullAvatar__3RB2h");
    const avatarURL = await GetCustomAvatar(username);
    if(avatarURL) avatarWrapperDiv.replaceChildren(
        HTML("img", {class: "custom-mwi-avatar", src: avatarURL})
    );
};
const UploadAvatar = async () => {
    const URLInput = document.getElementById("custom-avatar-url-input").value;
    const errorSpan = document.getElementById("custom-avatar-upload-error");
    try{
        const toURL = new URL(URLInput);
        if(toURL.protocol != "https:"){
            errorSpan.textContent = "输入的链接协议不是https";
        }
    }
    catch(e){
        if(e instanceof TypeError) {
            errorSpan.textContent = "输入的链接不是有效的URL";
            return;
        }
        else console.error(e);
    };
    errorSpan.textContent = "准备上传";
    const res = await fetch(`${RemoteHost}${UploadPath}`, {
        method: "POST",
        mode: "cors",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({
            username: PlayerUsername,
            imageURL: URLInput,
        })
    });
    if(res.status === 200) {
        errorSpan.textContent = "成功上传";
        avatarCache[PlayerUsername] = URLInput;
        SaveCache();
        RefreshAvatar();
    }
    else errorSpan.textContent = `上传失败:${res.status} ${await res.text()}`;
};
const ManualRefresh = async () => {
    const errorSpan = document.getElementById("custom-avatar-upload-error");
    avatarCache = undefined;
    lastUpdated = undefined;
    errorSpan.textContent = "准备刷新";
    try{
        await GetCustomAvatar(PlayerUsername);
    }
    catch(e){
        errorSpan.textContent = "刷新时出现错误,请联系VoltaX";
    }
    errorSpan.textContent = "刷新完成";
    RefreshAvatar();
};
const ShowHelp = () => {
    const errorSpan = document.getElementById("custom-avatar-upload-error");
    errorSpan.textContent = "帮助信息正在施工中";
};
const AddUploadInput = () => {
    const settingDiv = document.querySelector("div.SettingsPanel_profileTab__214Bj:not([avatar-upload-added])");
    if(!settingDiv) return;
    settingDiv.setAttribute("avatar-upload-added", "");
    const settingGrid = settingDiv.children[0];
    const frag = document.createDocumentFragment();
    frag.append(
        HTML("div", {class: "SettingsPanel_label__24LRD"}, "上传自定义头像"),
        HTML("div", {class: "SettingsPanel_value__2nsKD"},
            HTML("input", {id: "custom-avatar-url-input", class: "SettingsPanel_value__2nsKD Input_input__2-t98", placeholder: "输入自定义头像的图床链接"}),
            HTML("button", {class: "Button_button__1Fe9z", _click: UploadAvatar}, "上传"),
            HTML("button", {class: "Button_button__1Fe9z", _click: ShowHelp}, "帮助"),
            HTML("button", {class: "Button_button__1Fe9z", _click: ManualRefresh}, "刷新本地缓存"),
        ),
        HTML("div", {class: "SettingsPanel_label__24LRD"}),
        HTML("div", {class: "SettingsPanel_value__2nsKD"},
            HTML("span", {id: "custom-avatar-upload-error"}),
        ),
    );
    settingGrid.insertBefore(frag, settingGrid.children[0]);
}
const OnMutate = (mutlist, observer) => {
    observer.disconnect();
    ReplaceHeaderAvatar();
    ReplaceProfileAvatar();
    ReplaceCombatUnit();
    ReplacePartyMember();
    AddUploadInput();
    observer.observe(document, {subtree: true, childList: true});
};
const observer = new MutationObserver(OnMutate)
observer.observe(document, {subtree: true, childList: true});
const RefreshAvatar = () => {
    document.querySelectorAll("[avatar-modified]").forEach(ele => ele.removeAttribute("avatar-modified"));
    OnMutate([], observer);
};