Sololearn Code Comments

Use comment section features on web version of Sololearn playground

As of 2022-12-27. See the latest version.

// ==UserScript==
// @name         Sololearn Code Comments
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  Use comment section features on web version of Sololearn playground
// @author       DonDejvo
// @match        https://www.sololearn.com/compiler-playground/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=sololearn.com
// @grant        none
// @license MIT
// ==/UserScript==

(async () => {
    'use strict';

    class Store {
    static _instance;

    _token;
    _profile;

    static _get() {
        if (this._instance == null) {
            this._instance = new Store();
        }
        return this._instance;
    }

    static async login(userId, token) {
        this._get()._token = token;
        const data = await this.postAction("https://api3.sololearn.com/Profile/GetProfile", {
            excludestats: true,
            id: userId
        });
        this._get()._profile = data.profile;
    }

    static async postAction(url, body) {
        const res = await fetch(url, {
            headers: {
                "Content-Type": "application/json",
                "Authorization": "Bearer " + this._get()._token
            },
            referrer: "https://www.sololearn.com/",
            body: JSON.stringify(body),
            method: "POST",
            mode: "cors"
        });
        return await res.json();
    }

    static get profile() {
        return this._get()._profile;
    }
}

class Code {
    _data;
    _comments = [];
    _replies = [];

    static async load(publicId) {
        const data = await Store.postAction("https://api3.sololearn.com/Playground/GetCode", {
            publicId: publicId
        });
        return new Code(data);
    }

    constructor(data) {
        this._data = data;
    }

    _getReplies(parentId) {
        const elem = this._replies.find(elem => elem.parentId == parentId);
        return elem ? elem.comments : [];
    }

    _addReply(comment, parentId) {
        const elem = this._replies.find(elem => elem.parentId == parentId);
        if (elem) {
            elem.comments.push(comment);
        }
        else {
            this._replies.push({
                parentId,
                comments: [comment]
            });
        }
    }

    async _loadReplies(parentId, count) {
        const elem = this._replies.find(elem => elem.parentId == parentId);
        const index = elem ? elem.comments.length : 0;
        const data = await Store.postAction("https://api3.sololearn.com/Discussion/GetCodeComments", {
            codeId: this._data.code.id,
            count,
            index,
            orderBy: 1,
            parentId
        });
        for (let comment of data.comments) {
            this._addReply(comment, parentId);
        }
        return data;
    }

    _clearComments() {
        this._comments = [];
        this._replies = [];
    }

    getComments(parentId = null) {
        if (parentId == null) {
            return this._comments;
        }
        return this._getReplies(parentId);
    }

    async loadComments(parentId = null, count = 20) {
        if (parentId) {
            const data = await this._loadReplies(parentId, count);
            return data.comments;
        }
        const index = this._comments.length;
        const data = await Store.postAction("https://api3.sololearn.com/Discussion/GetCodeComments", {
            codeId: this._data.code.id,
            count,
            index,
            orderBy: 1,
            parentId
        });
        for (let comment of data.comments) {
            this._comments.push(comment);
        }
        return data.comments;
    }

    async createComment(message, parentId = null) {
        const data = await Store.postAction("https://api3.sololearn.com/Discussion/CreateCodeComment", {
            codeId: this._data.code.id,
            message,
            parentId
        });
        const comment = data.comment;
        if (parentId) {
            this._addReply(comment, parentId);
        }
        else {
            this._comments.push(comment);
        }
        return data.comment;
    }

    async deleteComment(id) {
        let toDelete;
        toDelete = this._comments.find(elem => elem.id == id);
        if (toDelete) {
            let idx;
            idx = this._comments.indexOf(toDelete);
            this._comments.splice(idx, 1);
            const elem = this._replies.find(elem => elem.parentId == id);
            if (elem) {
                idx = this._replies.indexOf(elem);
                this._replies.splice(idx, 1);
            }
        }
        else {
            for (let elem of this._replies) {
                for (let comment of elem.comments) {
                    if (comment.id == id) {
                        const idx = elem.comments.indexOf(comment);
                        elem.comments.splice(idx, 1);
                    }
                }
            }
        }
        await Store.postAction("https://api3.sololearn.com/Discussion/DeleteCodeComment", {
            id
        });
    }

    async editComment(message, id) {
        await Store.postAction("https://api3.sololearn.com/Discussion/EditCodeComment", {
            id,
            message
        });

    }

    render(root) {
        const modal = document.createElement("div");
        modal.style.display = "flex";
        modal.style.position = "absolute";
        modal.style.zIndex = 9999;
        modal.style.left = "0";
        modal.style.top = "0";
        modal.style.width = "100%";
        modal.style.height = "100%";
        modal.style.backgroundColor = "rgba(128, 128, 128, 0.5)";
        modal.style.alignItems = "center";
        modal.style.justifyContent = "center";

        const container = document.createElement("div");
        container.style.position = "relative";
        container.style.width = "600px";
        container.style.height = "800px";
        container.style.backgroundColor = "#fff";
        container.style.padding = "18px 12px";
        modal.appendChild(container);

        const closeBtn = document.createElement("button");
        closeBtn.innerHTML = "×";
        closeBtn.style.position = "absolute";
        closeBtn.style.right = "0";
        closeBtn.style.top = "0";
        closeBtn.addEventListener("click", () => {
            modal.style.display = "none";
        });

        const title = document.createElement("h1");
        title.textContent = this._data.code.comments + " comments";
        title.style.textAlign = "center";
        container.appendChild(title);
        container.appendChild(closeBtn);

        const commentsBody = document.createElement("div");
        commentsBody.style.width = "100%";
        commentsBody.style.height = "calc(100% - 60px)";
        commentsBody.style.overflowY = "auto";
        container.appendChild(commentsBody);

        const renderCreateCommentForm = () => {
            const createCommentForm = document.createElement("div");
            createCommentForm.style.display = "none";
            createCommentForm.style.position = "absolute";
            createCommentForm.style.width = "100%";

            const input = document.createElement("textarea");
            input.style.width = "100%";
            input.style.height = "120px";
            input.placeholder = "Write your comment here...";
            createCommentForm.appendChild(input);

            const buttonContainer = document.createElement("div");
            createCommentForm.appendChild(buttonContainer);

            const postButton = document.createElement("button");
            buttonContainer.appendChild(postButton);
            postButton.textContent = "Post";

            const cancelButton = document.createElement("button");
            buttonContainer.appendChild(cancelButton);
            cancelButton.textContent = "Cancel";

            return {
                createCommentForm,
                input,
                postButton,
                cancelButton
            };
        }

        const createComment = (comment) => {
            const container = document.createElement("div");
            container.style.width = "100%";

            const m = new Date(comment.date);
            const dateString = m.getUTCFullYear() + "/" +
                ("0" + (m.getUTCMonth() + 1)).slice(-2) + "/" +
                ("0" + m.getUTCDate()).slice(-2) + " " +
                ("0" + m.getUTCHours()).slice(-2) + ":" +
                ("0" + m.getUTCMinutes()).slice(-2) + ":" +
                ("0" + m.getUTCSeconds()).slice(-2);

            container.innerHTML = `<div style="display:flex; gap: 6px; padding: 6px 8px; margin-bottom: 8px;">
            <img style="width: 64px; height: 64px; border-radius: 50%; overflow: hidden; flex-shrink: 0;" src="${comment.avatarUrl}" alt="${comment.userName} - avatar">
            <div style="display: flex; flex-direction: column; flex-grow: 1;">
                <div style="display: flex; direction: row; justify-content: space-between;">
                    <div>${comment.userName}</div>
                    <div>${dateString}</div>
                </div>
                <div style="white-space: pre-wrap;">${comment.message.trim().replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>
                <div style="display: flex; justify-content: flex-end;">
                    <div style="display: flex; gap: 4px;">
                        <button ${comment.parentID === null ? "" : "disabled"} data-id="${comment.id}" class="toggle-replies-btn">${comment.replies} replies</button>
                        <button ${comment.parentID === null ? "" : "disabled"} data-id="${comment.id}" class="reply-btn">Reply</button>
                    </div>
                </div>
            </div>
            </div>
            <div data-id="${comment.id}" class="replies" style="display: none; border-top: 1px solid #000; border-bottom: 1px solid #000; padding: 4px 0;"></div>
            `;

            return container;
        }

        const renderLoadButton = (parentId, body) => {
            const container = document.createElement("button");
            container.textContent = "...";
            container.addEventListener("click", () => {
                body.removeChild(container);
                loadComments(body, parentId);
            });
            body.appendChild(container);
        }

        const loadComments = (body, parentId = null) => {
            this.loadComments(parentId)
                .then(comments => {
                    for (let comment of comments) {
                        body.append(createComment(comment));
                    }
                    if (comments.length) {
                        renderLoadButton(parentId, body);
                    }
                });
        }

        const { createCommentForm, input, postButton, cancelButton } = renderCreateCommentForm();
        container.appendChild(createCommentForm);

        const openCommentForm = (parentId = null) => {
            createCommentForm.style.display = "block";
            createCommentForm.dataset.parentId = parentId;
        }

        const getRepliesContainer = (commentId) => {
            let out = null;
            const replies = document.querySelectorAll(".replies");
            replies.forEach(elem => {
                if (commentId == elem.dataset.id) {
                    out = elem;
                }
            });
            return out;
        }

        const showCommentFormButton = document.createElement("button");
        showCommentFormButton.textContent = "Post comment";
        container.appendChild(showCommentFormButton);
        showCommentFormButton.addEventListener("click", () => openCommentForm());

        const postComment = () => {
            const parentId = createCommentForm.dataset.parentId == "null" ? null : +createCommentForm.dataset.parentId;
            this.createComment(input.value, parentId)
                .then(comment => {
                    input.value = "";
                    createCommentForm.style.display = "none";
                    comment.userName = Store.profile.name;
                    comment.avatarUrl = Store.profile.avatarUrl;
                    comment.replies = 0;
                    if (parentId === null) {
                        commentsBody.prepend(createComment(comment));
                    }
                    else {
                        getRepliesContainer(parentId).append(createComment(comment));
                        const toggleReplyButtons = document.querySelectorAll(".toggle-replies-btn");
                        toggleReplyButtons.forEach(elem => {
                            if (parentId == elem.dataset.id) {
                                elem.textContent = (+elem.textContent.split(" ")[0] + 1) + " replies";
                            }
                        });
                    }
                });
        }

        postButton.addEventListener("click", () => postComment());
        cancelButton.addEventListener("click", () => createCommentForm.style.display = "none");

        loadComments(commentsBody);

        root.appendChild(modal);

        addEventListener("click", ev => {
            if (ev.target.classList.contains("toggle-replies-btn")) {
                const elem = getRepliesContainer(ev.target.dataset.id);
                if (elem.classList.contains("replies_opened")) {
                    elem.style.display = "none";
                }
                else {
                    elem.style.display = "block";
                    loadComments(elem, ev.target.dataset.id);
                }
                elem.classList.toggle("replies_opened");
            }
            else if (ev.target.classList.contains("reply-btn")) {
                const elem = getRepliesContainer(ev.target.dataset.id);
                if (!elem.classList.contains("replies_opened")) {
                    elem.style.display = "block";
                    loadComments(elem, ev.target.dataset.id);
                    elem.classList.add("replies_opened");
                }

                openCommentForm(ev.target.dataset.id);
            }
        });
        return modal;
    }

}

const main = async () => {

    const userId = JSON.parse(localStorage.getItem("user")).data.id;
    const accessToken = JSON.parse(localStorage.getItem("accessToken")).data;
    const publicId = window.location.pathname.split("/")[2];

    await Store.login(
        userId,
        accessToken
    );

    const code = await Code.load(publicId);
    const modal = code.render(document.querySelector(".sl-playground-wrapper"));
    modal.style.display = "none";

    const openModalButton = document.createElement("button");
    openModalButton.textContent = "Show comments";
    openModalButton.addEventListener("click", () => modal.style.display = "flex");
    document.querySelector(".sl-playground-left").appendChild(openModalButton);
}

setTimeout(main, 1000);

function getCookie(cookieName) {
    let cookie = {};
    document.cookie.split(';').forEach(function(el) {
        let [key,value] = el.split('=');
        cookie[key.trim()] = value;
    });
    return cookie[cookieName];
}

})();