Multiplayer Piano Optimizations [Edit]

Edit your messages in chat! (edited)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Multiplayer Piano Optimizations [Edit]
// @namespace    https://tampermonkey.net/
// @version      1.0.4
// @description  Edit your messages in chat! (edited)
// @author       zackiboiz
// @match        *://*.multiplayerpiano.com/*
// @match        *://*.multiplayerpiano.net/*
// @match        *://*.multiplayerpiano.org/*
// @match        *://*.multiplayerpiano.dev/*
// @match        *://piano.mpp.community/*
// @match        *://mpp.7458.space/*
// @match        *://qmppv2.qwerty0301.repl.co/*
// @match        *://mpp.8448.space/*
// @match        *://mpp.hri7566.info/*
// @match        *://mpp.autoplayer.xyz/*
// @match        *://mpp.hyye.xyz/*
// @match        *://lmpp.hyye.xyz/*
// @match        *://mpp.hyye.tk/*
// @match        *://mpp.smp-meow.net/*
// @match        *://piano.ourworldofpixels.com/*
// @match        *://mpp.lapishusky.dev/*
// @match        *://staging-mpp.sad.ovh/*
// @match        *://mpp.terrium.net/*
// @match        *://mpp.yourfriend.lv/*
// @match        *://mpp.l3m0ncao.wtf/*
// @match        *://beta-mpp.csys64.com/*
// @match        *://fleetway-mpp.glitch.me/*
// @match        *://mpp.totalh.net/*
// @match        *://mpp.meowbin.com/*
// @match        *://mppfork.netlify.app/*
// @match        *://better.mppclone.me/*
// @match        *://*.openmpp.tk/*
// @match        *://*.mppkinda.com/*
// @match        *://*.augustberchelmann.com/piano/*
// @match        *://mpp.c30.life/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=multiplayerpiano.net
// @grant        GM_info
// @require      https://code.jquery.com/jquery-2.2.4.min.js
// @license      MIT
// ==/UserScript==

(async () => {
    const dl = GM_info.script.downloadURL || GM_info.script.updateURL || GM_info.script.homepageURL || "";
    const match = dl.match(/greasyfork\.org\/scripts\/(\d+)/);
    if (!match) {
        console.warn("Could not find Greasy Fork script ID in downloadURL/updateURL/homepageURL:", dl);
    } else {
        const scriptId = match[1];
        const localVersion = GM_info.script.version;
        const apiUrl = `https://greasyfork.org/scripts/${scriptId}.json?_=${Date.now()}`;

        fetch(apiUrl, {
            mode: "cors",
            headers: {
                Accept: "application/json"
            }
        }).then(r => {
            if (!r.ok) throw new Error("Failed to fetch Greasy Fork data.");
            return r.json();
        }).then(data => {
            const remoteVersion = data.version;
            if (compareVersions(localVersion, remoteVersion) < 0) {
                new MPP.Notification({
                    "m": "notification",
                    "duration": 15000,
                    "title": "Update Available",
                    "html": "<p>A new version of this script is available!</p>" +
                        `<p style='margin-top: 10px;'>Script: ${GM_info.script.name}</p>` +
                        `<p>Local: v${localVersion}</p>` +
                        `<p>Latest: v${remoteVersion}</p>` +
                        `<a href='https://greasyfork.org/scripts/${scriptId}' target='_blank' style='position: absolute; right: 0;bottom: 0; margin: 10px; font-size: 0.5rem;'>Open Greasy Fork to update?</a>`
                });
            }
        }).catch(err => console.error("Update check failed:", err));
    }

    function compareVersions(a, b) {
        const pa = a.split(".").map(n => parseInt(n, 10) || 0);
        const pb = b.split(".").map(n => parseInt(n, 10) || 0);
        const len = Math.max(pa.length, pb.length);
        for (let i = 0; i < len; i++) {
            if ((pa[i] || 0) < (pb[i] || 0)) return -1;
            if ((pa[i] || 0) > (pb[i] || 0)) return 1;
        }
        return 0;
    }

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
    await sleep(1000);


    const MAX_MESSAGE_LENGTH = 512;
    const DB_NAME = "mppo-edit";
    const DB_VERSION = 1;
    const STORE_NAME = "edits";

    let messages = [];
    let messageElems = [];
    let userId;
    let channelId;
    let editingId;
    let isEditing = false;
    let editingDmRecipient;
    let replyPart = {};

    function openDB() {
        return new Promise((resolve, reject) => {
            const req = indexedDB.open(DB_NAME, DB_VERSION);
            req.onupgradeneeded = (e) => {
                const db = e.target.result;
                if (!db.objectStoreNames.contains(STORE_NAME)) {
                    const os = db.createObjectStore(STORE_NAME, {
                        keyPath: "id"
                    });
                    os.createIndex("channelId", "channelId", {
                        unique: false
                    });
                }
            };
            req.onsuccess = () => resolve(req.result);
            req.onerror = () => reject(req.error);
        });
    }

    async function saveEdit(id, channelId, message) {
        const db = await openDB();

        const tx = db.transaction(STORE_NAME, "readwrite");
        tx.objectStore(STORE_NAME).put({
            id: id,
            channelId: channelId,
            message: message,
            timestamp: Date.now()
        });
        tx.oncomplete = () => db.close();
    }

    async function deleteEdit(id) {
        const db = await openDB();

        const tx = db.transaction(STORE_NAME, "readwrite");
        tx.objectStore(STORE_NAME).delete(id);
        tx.oncomplete = () => db.close();
    }

    async function getEdits(channelId) {
        const db = await openDB();
        return await new Promise((resolve, reject) => {
            const tx = db.transaction(STORE_NAME, "readonly");
            const idx = tx.objectStore(STORE_NAME).index("channelId");

            const req = idx.getAll(IDBKeyRange.only(channelId));
            req.onsuccess = () => {
                resolve(req.result || []);
                db.close();
            };
            req.onerror = () => {
                reject(req.error);
                db.close();
            };
        });
    }

    async function cleanupEdits(channelId, existingIds) {
        const db = await openDB();
        const tx = db.transaction(STORE_NAME, "readwrite");
        const idx = tx.objectStore(STORE_NAME).index("channelId");

        const req = idx.openCursor(IDBKeyRange.only(channelId));
        req.onsuccess = (e) => {
            const cursor = e.target.result;
            if (cursor) {
                const rec = cursor.value;
                if (!existingIds.includes(rec.id)) cursor.delete();
                cursor.continue();
            }
        };
        tx.oncomplete = () => db.close();
    }

    function isConnected() {
        return !!(MPP && MPP.client && MPP.client.isConnected());
    }

    function check() {
        if (!isConnected()) return setTimeout(check, 200);
        run();
    }

    function run() {
        MPP.client.on("c", async (data) => {
            userId = MPP.client.participantId;
            channelId = MPP.client.channel._id;
            messages = data.c;

            const saved = await getEdits(channelId);
            for (const edit of saved) {
                const msg = messages.find(m => m.id === edit.id);
                if (!msg) continue;

                editMessage(msg.id, edit.message);
            }

            const existingIds = messages.map(m => m.id);
            await cleanupEdits(channelId, existingIds);

            scanAndAdd();
        });

        MPP.client.on("a", (data) => {
            messages.push(data);
            scanAndAdd();
        });
        MPP.client.on("dm", (data) => {
            messages.push(data);
            scanAndAdd();
        });
        MPP.client.on("custom", async (data) => {
            handleEdit(data);
        });

        function unsetEdit(id, clean = true) {
            if (clean) {
                MPP.chat.endDM();
                MPP.chat.cancelReply();
            }
            $(`#msg-${id}`).css({
                "background-color": "unset",
                border: "1px solid #00000000",
            });
        }
        function unsetAttrs() {
            editingId = null;
            isEditing = false;
            editingDmRecipient = null;
        }

        async function handleEdit(data) {
            const payload = data.data;
            if (payload?.m !== "edit") return;
            if (!payload.id || !payload.message ||
                typeof payload.id !== "string" ||
                typeof payload.message !== "string"
            ) return;

            const id = payload.id;
            const msg = messages.find(m => m.id === id);
            if (!msg ||
                (msg.m === "a" && msg.p?._id !== data.p) ||
                (msg.m === "dm" && msg.sender?._id !== data.p)
            ) return;
            if (payload.message.length > MAX_MESSAGE_LENGTH) return;

            editMessage(id, payload.message);
            saveEdit(id, channelId, payload.message);
        }

        function editMessage(id, message) {
            const msg = messages.find(m => m.id === id);
            if (!msg || msg.a === message) return false;
            msg.a = message;

            const msgElem = document.querySelector(`#msg-${id}`);
            if (!msgElem) return;
            const spanMsg = msgElem.querySelector("span.message");
            if (spanMsg) spanMsg.textContent = message;

            if (!msgElem.querySelector("span.edited")) {
                const edited = document.createElement("span");
                edited.className = "edited";
                edited.style.color = "#777";
                edited.style.fontSize = "0.75em";
                edited.textContent = "(edited)";
                msgElem.insertAdjacentElement("beforeend", edited);
            }

            return true;
        }

        async function scanAndAdd() {
            const ul = document.querySelector("div#chat > ul");
            if (!ul) return;

            for (const li of ul.querySelectorAll(":scope > li")) {
                if (!(li instanceof HTMLElement) || li.tagName !== "LI") continue;
                if (li.querySelector("span.edit") || li.className.includes("editable")) continue;

                li.classList.add("editable");
                const existingReply = li.querySelector("span.reply");
                if (existingReply) {
                    existingReply.addEventListener("click", function () {
                        unsetEdit(editingId, false);
                        unsetAttrs();
                    });
                }

                const id = li.id.split("-")[1];
                const msg = messages.find(m => m.id === id);

                if (!msg ||
                    (msg.m === "a" && msg.p?._id !== userId) ||
                    (msg.m === "dm" && msg.sender?._id !== userId)
                ) continue;

                const editButton = document.createElement("span");
                editButton.className = "edit";
                editButton.style.marginRight = "4px";
                editButton.style.paddingInline = "5px";
                editButton.style.backgroundColor = "#111";
                editButton.style.border = "1px solid #444";
                editButton.style.textShadow = "none";
                editButton.style.borderRadius = "2px";
                editButton.style.webkitBorderRadius = "2px";
                editButton.style.cursor = "pointer";
                editButton.textContent = "✎";

                if (existingReply) existingReply.insertAdjacentElement("afterend", editButton);
                else li.appendChild(editButton);

                messageElems.push(li);

                editButton.addEventListener("click", function () {
                    unsetEdit(editingId);

                    isEditing = true;
                    if (msg.m === "dm") editingDmRecipient = msg.recipient._id;
                    editingId = id;
                    setTimeout(() => {
                        $(`#msg-${id}`).css({
                            border: `1px solid ${msg?.m === "dm" ? msg.sender?.color : msg.p?.color}80`,
                            "background-color": `${msg?.m === "dm" ? msg.sender?.color : msg.p?.color}20`,
                        });
                    }, 100);
                    setTimeout(() => {
                        $("#chat-input").focus();
                    }, 100);

                    $("#chat-input")[0].placeholder = "Editing a message" + (msg.m === "dm" ? " in a DM" : "");
                    $("#chat-input")[0].value = msg.a;
                });
            }
        }

        const originalChatSend = MPP.chat.send;
        const originalStartDM = MPP.chat.startDM;
        MPP.chat.send = function (message) {
            if (!isEditing) return originalChatSend.apply(this, arguments);
            if (!editMessage(editingId, message)) return;

            MPP.client.sendArray([{
                m: "custom",
                data: {
                    m: "edit",
                    id: editingId,
                    message: message
                },
                target: editingDmRecipient ? {
                    mode: "id",
                    id: editingDmRecipient
                } : {
                    mode: "subscribed"
                }
            }]);

            saveEdit(editingId, channelId, message);
            unsetEdit(editingId);
            unsetAttrs();
        };
        MPP.chat.startDM = function () {
            unsetEdit(editingId, false);
            unsetAttrs();
            return originalStartDM.apply(this, arguments);
        }

        scanAndAdd();
        const triggerObserver = new MutationObserver(() => {
            scanAndAdd();
        });

        const chatRoot = document.querySelector("div#chat > ul");
        if (chatRoot) {
            triggerObserver.observe(chatRoot, {
                childList: true,
                subtree: true
            });
        }

        const chatInput = document.querySelector("#chat-input");
        chatInput.addEventListener("keydown", function (e) {
            if (e.keyCode === 13 && isEditing) {
                unsetEdit(editingId);
                unsetAttrs();
            }
        });
    }

    check();
})();