Greasy Fork is available in English.

Komica notify

Notify new post every 60seconds on komica

// ==UserScript==
// @name         Komica notify
// @namespace    https://github.com/usausausausak
// @description  Notify new post every 60seconds on komica
// @include      http://*.komica.org/*/pixmicat.php?res=*
// @include      https://*.komica.org/*/pixmicat.php?res=*
// @include      http://*.komica2.net/*/pixmicat.php?res=*
// @include      https://*.komica2.net/*/pixmicat.php?res=*
// @version      1.3.0
// ==/UserScript==
(function (window) {
    const selfId = "[Komica_notify]";

    const GET_POST_LIST_URL = "pixmicat.php?mode=module&load=mod_ajax&action=thread&html=true&op=";
    const FETCH_TIMEOUT = 30 * 1000;
    const PULL_INTERVAL = 60 * 1000;

    const NOTIFICATION_TIMEOUT = 4000;

    const pageTitle = document.title;
    const pageUrl = document.location.toString();

    const threadPost = document.getElementsByClassName("threadpost")[0];
    const threadNo = threadPost.dataset.no;
    let threadNewPost = 0;
    let threadIsRead = true;

    let updateTimer = null;
    // fetch url must be absolute url in userscript
    const postsFetchURL = pageUrl.substr(0, pageUrl.lastIndexOf('/'))
                          + '/' + GET_POST_LIST_URL + threadNo;

    const savedPostNos = new Set();
    document.querySelectorAll(".post")
        .forEach(post => savedPostNos.add(post.dataset.no));

    function setGetDiff(lhs, rhs) {
        let set = new Set(lhs);
        rhs.forEach(v => set.delete(v));
        return set;
    }

    async function wait(msec) {
        return new Promise(resolve => setTimeout(() => resolve(), msec));
    }

    function showNotification(unreadSize, newPostSize) {
        if (!window.Notification) {
            return;
        }

        const postImg = threadPost.querySelector(".file-thumb img");
        const postBodyView = threadPost.querySelector(".quote");

        let title = "";
        if (postBodyView.firstChild) {
            title = postBodyView.firstChild.textContent;
        }

        let msg = `新着 ${newPostSize} 件`;
        if (unreadSize > 0) {
            msg += `、未読 ${unreadSize} 件`;
        }
        const options = {
            tag: threadNo,
            body: msg,
            icon: postImg && postImg.src,
        }

        let notify = new Notification(title, options);
        notify.addEventListener("click", () => {
            location.href = "#notify-unread-flag";
        }, false);
        setTimeout(() => notify.close(), NOTIFICATION_TIMEOUT);
    }

    async function getPosts() {
        return Promise.race([
            fetch(postsFetchURL).then(res => res.json()),
            wait(FETCH_TIMEOUT).then(() => Promise.reject("timeout"))
        ]);
    }

    function setDeletedPost(postNo) {
        let post = document.querySelector(`#r${postNo}`);
        if (post) {
            post.style.opacity = 0.5;
            post.style.transition = "opacity 100ms";
        }
    }

    function removeUnreadFlag() {
        let flag = document.querySelector("#notify-unread-flag");
        if (flag) {
            flag.parentElement.removeChild(flag);
        }
    }

    function getUnreadFlagElement() {
        let flag = document.querySelector("#notify-unread-flag");
        if (!flag) {
            flag = document.createElement("span");
            flag.id = "notify-unread-flag";
            flag.className = "warn_txt2";
            flag.innerHTML = "ここまで読んだ";
            flag.style = `display: flex; justify-content: center;
                          width: 30%;`;
        }
        return flag;
    }

    async function elementFadeIn(element, interval, factor) {
        if (element.style.opacity === null) {
            element.style.opacity = 0;
        }

        let opacity = +element.style.opacity; // style.opacity is a string
        if (opacity >= 1) {
            element.style.opacity = null;
        } else {
            element.style.opacity = factor + opacity;
            await wait(interval);
            elementFadeIn(element, interval, factor);
        }
    }

    async function displayPost(iter) {
        let value = iter.next();
        if (value.done) {
            return;
        }

        let post = value.value[1];
        // fadein in javascript to reduce impact on stylesheet
        elementFadeIn(post, 10, 0.1); // total 100ms

        await wait(50);
        displayPost(iter);
    }

    function appendNewPosts(postNos, postDataMap) {
        console.log(selfId, `Have new post: ${postNos.size}.`);

        let container = document.querySelector("#threads > .thread");
        let insertPoint = container.querySelector("hr");

        // insert read flag if necessary
        if (threadIsRead) {
            container.insertBefore(getUnreadFlagElement(), insertPoint);
        }

        showNotification(threadNewPost, postNos.size);

        // append to unread post size
        threadNewPost += postNos.size;
        threadIsRead = false;

        // render posts
        let posts = [];
        for (let postNo of postNos) {
            if (!postDataMap.has(postNo)) {
                continue;
            }
            let postData = postDataMap.get(postNo);

            let postBlock = document.createElement("div");
            postBlock.innerHTML = postData.html.replace(/^[^<]*/, "");

            let post = postBlock.childNodes[0];
            post.style.opacity = 0;

            container.insertBefore(post, insertPoint);
            savedPostNos.add(postNo);
            posts.push(post);
        }

        // fadein new posts
        let iter = posts.entries();
        displayPost(iter);
    }

    function activeScript() {
        // remove exists event
        let elementsWithEvent = ([
            "#postform_main",
            ".post-head > span",
            ".file-thumb",
        ]).join(",");

        document.querySelectorAll(elementsWithEvent).forEach(element => {
            let clone = element.cloneNode();
            while (element.firstChild) {
                clone.appendChild(element.firstChild);
            }
            element.parentNode.replaceChild(clone, element);
        });

        // remove auto created element
        let elementsParentNeedRemove = ([
            ".quote .-expand-youtube",
        ]).join(",");
        document.querySelectorAll(elementsParentNeedRemove).forEach(element => {
            element = element.parentNode;
            element.parentNode.removeChild(element);
        });

        // run script.js
        let script = document.createElement("script");
        script.setAttribute("src","/common/js/script.js");
        document.head.appendChild(script);
        script.addEventListener("load",
                                function () {
                                    document.head.removeChild(this);
                                },
                                false);
    }

    async function updateWait(msec) {
        return new Promise(resolve => {
            updateTimer = setTimeout(() => resolve(), msec);
        });
    }

    async function updatePosts(immediately = false) {
        clearTimeout(updateTimer);
        updateTimer = null;

        if (!immediately) {
            await updateWait(PULL_INTERVAL);
        }

        if (Notification.permission === "default") {
            Notification.requestPermission();
        }

        startLoad();
        try {
            let postDatas = (await getPosts()).posts;

            let postDataMap = new Map(
                postDatas.map(post => [post.no.toString(), post]));

            let postNos = postDatas.map(post => post.no.toString());

            // find deleted post
            let deletedPostNos = setGetDiff(savedPostNos, postNos);
            deletedPostNos.forEach(setDeletedPost);

            // find new posts
            let newPostNos = setGetDiff(postNos, savedPostNos);

            if (newPostNos.size) {
                appendNewPosts(newPostNos, postDataMap);

                try {
                    activeScript();
                } catch (ex) {
                    console.error(selfId, ex);
                }

                // notification
                document.title = `${pageTitle} (${threadNewPost})`;

                window.postMessage({ event: "notify-new-posts",
                                     posts: Array.from(newPostNos)
                                   } , "*");
            } else {
                if (threadIsRead) {
                    removeUnreadFlag();
                }
                console.log(selfId, "No new post.")
            }
        } catch (ex) {
            console.error(selfId, `Fail: ${ex}, retry at ${PULL_INTERVAL}.`);
        }

        endLoad();

        // next iterate
        console.log(selfId, `Last update: ${new Date()}.`);
        updatePosts();
    }

    // reload button
    function isLoading() {
        return reloadButton.classList.contains("notify-disabled");
    }

    function startLoad() {
        reloadButton.classList.add("notify-disabled");
        reloadButton.classList.remove("text-button");
        reloadButton.innerHTML = "読み込み中…";
    }

    function endLoad() {
        reloadButton.classList.remove("notify-disabled");
        reloadButton.classList.add("text-button");
        reloadButton.innerHTML = "[再読み込み]";
    }

    function manualReload() {
        if (!isLoading()) {
            threadNewPost = 0;
            threadIsRead = true;

            updatePosts(true).then(() => {
                reloadButton.innerHTML = `新着 ${threadNewPost} 件。[再読み込み]`;
            });
        }
    }

    let block = document.createElement("div");
    block.style = "display: flex; justify-content: center; width: 30%;";

    let reloadButton = document.createElement("span");
    reloadButton.id = "notify-reload";
    reloadButton.innerHTML = "[再読み込み]";
    reloadButton.className = "text-button";
    reloadButton.addEventListener("click", manualReload, false);

    let container = document.querySelector("#threads");
    block.appendChild(reloadButton);
    container.appendChild(block);

    // treat as read if scrolled
    function readPostCb(ev) {
        document.title = pageTitle;

        threadNewPost = 0;
        threadIsRead = true;
    }
    window.addEventListener("scroll", readPostCb, false);

    // event from other script
    function fetchNewPostCb(ev) {
        let event = ev.data;
        if (event.event === "fetch-new-posts") {
            manualReload();
        }
    }

    window.addEventListener("message", fetchNewPostCb, false);

    // start auto reload
    updatePosts();
})(window);
// vim: set sw=4 ts=4 sts=4 et: