Greasy Fork

Greasy Fork is available in English.

水源显示回复可见

可在水源论坛显示仅回复可见的回帖内容(需要手动点击按钮)

当前为 2021-10-07 提交的版本,查看 最新版本

// ==UserScript==
// @name         水源显示回复可见
// @namespace    CCCC_David
// @version      0.2.0
// @description  可在水源论坛显示仅回复可见的回帖内容(需要手动点击按钮)
// @author       CCCC_David
// @match        https://shuiyuan.sjtu.edu.cn/*
// @grant        none
// ==/UserScript==

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

    // From Font Awesome Free v5.15 by @fontawesome - https://fontawesome.com
    // License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
    // Modified class attribute to fit in.
    const prevReplyIcon = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-left" class="svg-icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path fill="currentColor" d="M192 127.338v257.324c0 17.818-21.543 26.741-34.142 14.142L29.196 270.142c-7.81-7.81-7.81-20.474 0-28.284l128.662-128.662c12.599-12.6 34.142-3.676 34.142 14.142z"></path></svg>';
    const nextReplyIcon = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-right" class="svg-icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path fill="currentColor" d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z"></path></svg>';

    const allowedPolicy = window.trustedTypes?.createPolicy?.('allowedPolicy', {createHTML: (x) => x});
    const createTrustedHTML = (html) => (allowedPolicy ? allowedPolicy.createHTML(html) : html);

    const htmlParser = new DOMParser();

    const escapeRegExpOutsideCharacterClass = (s) => s.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');

    const discourseFetch = (url, options) => fetch(url, {
        method: options?.method ?? 'GET',
        headers: {
            'Discourse-Present': 'true',
            'Discourse-Logged-In': 'true',
            'X-Requested-With': 'XMLHttpRequest',
            'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
            ...options?.headers,
        },
        body: options?.body,
        mode: 'same-origin',
        credentials: 'include',
    });

    const fetchReply = async (topicId, replyId) => {
        const replyURL = `/t/topic/${topicId}/${replyId}`;
        const response = await discourseFetch(`/onebox?url=${encodeURIComponent(replyURL)}`);
        return response.text();
    };

    const fetchTopicInfo = async (topicId) => {
        const [topicInfo1, topicInfo2] = await Promise.all([
            `/t/${topicId}.json`,
            `/latest.json?topic_ids=${topicId}`,
        ].map(async (url) => (await discourseFetch(url, {headers: {Accept: 'application/json'}})).json()));
        return {
            isPrivateReply: topicInfo1.private_replies,
            maxReplyId: topicInfo2.topic_list.topics[0].highest_post_number,
            participants: topicInfo1.details?.participants ?? [],
        };
    };

    const fetchUserInfo = async (username) => {
        if (!username) {
            return {
                username: null,
                name: null,
                title: null,
            };
        }
        const response = await discourseFetch(`/u/${encodeURIComponent(username)}/card.json`, {headers: {Accept: 'application/json'}});
        const userInfo = await response.json();
        return {
            username: userInfo.user.username,
            name: userInfo.user.name,
            title: userInfo.user.title,
        };
    };

    const renderReply = (tree) => {
        const blockquote = tree.querySelector('aside > blockquote');
        if (!blockquote) {
            return '(帖子已被作者删除)';
        }
        const reply = blockquote.innerHTML.trim();
        if (!reply) {
            return '&nbsp;';
        }
        // eslint-disable-next-line no-undef
        return require('discourse/lib/text').cookAsync(reply);
    };

    const getAvatarURLFromReply = (tree) => tree.querySelector('aside > div > img')?.src ?? '';

    const tryToFindUserByAvatar = async (avatarURL, participants) => {
        // Case 1: Extract username from URL if it is user avatar.
        const username = avatarURL.match(/\/user_avatar\/(?:[^/]+)\/([^/]+)\//u)?.[1];
        if (username) {
            return {
                username: decodeURIComponent(username),
                name: null,
                title: null,
            };
        }
        // Case 2: Try to match letter avatar with top participants of this topic.
        const letterAvatarMatch = avatarURL.match(/\/letter_avatar_proxy\/(?:[^/]+)\/letter\/([^/]+)\/([^/]+)\//u); // Letter and color
        if (!letterAvatarMatch) { // Unexpected format.
            return {
                username: null,
                name: null,
                title: null,
            };
        }
        const letterAvatarTag = ['letter', letterAvatarMatch[1], letterAvatarMatch[2]].join('/');
        for (const participant of participants) {
            if ((participant.avatar_template ?? '').includes(letterAvatarTag)) {
                return {
                    username: participant.username,
                    name: participant.name,
                    title: null,
                };
            }
        }
        // Case 3: Search for users with the letter and try to match letter avatar.
        const searchUsersByLetterResult = await (await discourseFetch(`/directory_items?period=all&order=username&name=${encodeURIComponent(decodeURIComponent(letterAvatarMatch[1]))}`)).json();
        for (const user of searchUsersByLetterResult.directory_items) {
            if ((user.user.avatar_template ?? '').includes(letterAvatarTag)) {
                return {
                    username: user.user.username,
                    name: user.user.name,
                    title: user.user.title,
                };
            }
        }
        // We are unable to figure out the username by avatar.
        return {
            username: null,
            name: null,
            title: null,
        };
    };

    const addViewer = async (parentNode) => {
        if (!parentNode) {
            return null;
        }

        // Do not add the viewer more than once for a page.
        if (document.getElementById('show-private-reply-div')) {
            return null;
        }

        const topicId = parseInt(window.location.pathname.match(/^\/t\/topic\/(\d+)(?=\/|$)/u)?.[1], 10);

        if (Number.isNaN(topicId)) { // Unable to parse topic ID, maybe not a topic page, give up.
            return null;
        }

        const {isPrivateReply, maxReplyId, participants} = await fetchTopicInfo(topicId);

        // Do not add viewer if current topic is not private reply.
        // Double check for race condition after await.
        if (!isPrivateReply || document.getElementById('show-private-reply-div')) {
            return null;
        }

        const viewerContainer = document.createElement('div');
        viewerContainer.id = 'show-private-reply-div';
        viewerContainer.innerHTML = createTrustedHTML(`
            <div class="topic-post clearfix regular">
                <article role="region" class="boxed">
                    <div class="row">
                        <div class="topic-avatar">
                            <a id="show-private-reply-avatar" class="trigger-user-card main-avatar" href="" data-user-card="" aria-hidden="true">
                                <img width="45" height="45" src="" class="avatar">
                            </a>
                        </div>
                        <div class="topic-body clearfix">
                            <div role="heading" class="topic-meta-data">
                                <div class="names trigger-user-card">
                                    <span class="first full-name">
                                        <a id="show-private-reply-name" href="" data-user-card="">&nbsp;</a>
                                    </span>
                                    <span class="second username">
                                        <a id="show-private-reply-username" href="" data-user-card=""></a>
                                    </span>
                                    <span id="show-private-reply-user-title" class="user-title"></span>
                                </div>
                                <span style="text-align: right; margin-left: auto;">
                                    <span id="show-private-reply-id" style="font-size: var(--font-up-2); cursor: pointer;">1</span>
                                    <button id="show-private-reply-dec-id" class="btn" style="margin-left: 0.5em;">${prevReplyIcon}</button>
                                    <button id="show-private-reply-inc-id" class="btn" style="margin-left: 0.5em;">${nextReplyIcon}</button>
                                </span>
                            </div>
                            <div class="regular contents">
                                <div id="show-private-reply-content" class="cooked">正在加载回复可见内容...</div>
                            </div>
                        </div>
                    </div>
                </article>
            </div>
        `);
        parentNode.appendChild(viewerContainer);
        const replyAvatarElement = document.getElementById('show-private-reply-avatar');
        const replyNameElement = document.getElementById('show-private-reply-name');
        const replyUsernameElement = document.getElementById('show-private-reply-username');
        const replyUserTitleElement = document.getElementById('show-private-reply-user-title');
        const replyIdElement = document.getElementById('show-private-reply-id');
        const replyContentElement = document.getElementById('show-private-reply-content');

        const updateView = async (replyId) => {
            replyIdElement.textContent = replyId.toString();
            const reply = await fetchReply(topicId, replyId);
            const tree = htmlParser.parseFromString(reply, 'text/html');
            const avatarURL = getAvatarURLFromReply(tree);
            // eslint-disable-next-line no-shadow
            let {username, name, title} = await tryToFindUserByAvatar(avatarURL, participants);
            if (title === null) {
                ({username, name, title} = await fetchUserInfo(username));
            }
            const nameToShow = name || username;
            replyAvatarElement.href = replyNameElement.href = replyUsernameElement.href = username ? `/u/${encodeURIComponent(username)}` : '';
            for (const userCardElement of [replyAvatarElement, replyNameElement, replyUsernameElement]) {
                userCardElement.setAttribute('data-user-card', username ?? '');
                // eslint-disable-next-line no-undef
                jQuery(userCardElement).data('user-card', username ?? '');
            }
            replyAvatarElement.children[0].src = avatarURL;
            replyAvatarElement.style = `visibility: ${avatarURL ? 'visible' : 'hidden'}; cursor: ${username ? 'pointer' : 'default'};`;
            replyNameElement.textContent = nameToShow || '未知用户的回复';
            replyNameElement.style = `cursor: ${nameToShow ? 'pointer' : 'text'};`;
            replyUsernameElement.textContent = username;
            replyUsernameElement.parentNode.style = name && name !== username ? '' : 'display: none;';
            replyUserTitleElement.textContent = title;
            replyUserTitleElement.style = title ? '' : 'display: none;';
            replyContentElement.innerHTML = createTrustedHTML(await renderReply(tree));
        };

        for (const userCardElement of [replyAvatarElement, replyNameElement, replyUsernameElement]) {
            userCardElement.addEventListener('click', (e) => {
                if (!userCardElement.getAttribute('data-user-card')) {
                    e.preventDefault();
                    e.stopPropagation();
                }
            });
        }

        document.getElementById('show-private-reply-inc-id').addEventListener('click', () => {
            let replyId = parseInt(replyIdElement.textContent, 10);
            replyId = replyId < maxReplyId ? replyId + 1 : 1;
            return updateView(replyId);
        });

        document.getElementById('show-private-reply-dec-id').addEventListener('click', () => {
            let replyId = parseInt(replyIdElement.textContent, 10);
            replyId = replyId > 1 ? replyId - 1 : maxReplyId;
            return updateView(replyId);
        });

        replyIdElement.addEventListener('click', () => {
            const newReplyIdText = prompt('跳转到回复...', replyIdElement.textContent);
            if (!newReplyIdText) {
                return null;
            }
            let newReplyId = parseInt(newReplyIdText, 10);
            if (Number.isNaN(newReplyId)) {
                newReplyId = 1;
            }
            newReplyId = Math.min(Math.max(newReplyId, 1), maxReplyId);
            return updateView(newReplyId);
        });

        return updateView(parseInt(replyIdElement.textContent, 10));
    };

    const appendToTargetClass = 'post-stream';

    const observer = new MutationObserver(async (mutationsList) => {
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList') {
                for (const el of mutation.addedNodes) {
                    if (el.classList?.contains(appendToTargetClass)) {
                        // eslint-disable-next-line no-await-in-loop -- addViewer should ideally only happen once
                        await addViewer(el);
                    }
                }
            } else if (mutation.type === 'attributes') {
                if (!mutation.oldValue?.match(new RegExp(`(?:^|\\s)${escapeRegExpOutsideCharacterClass(appendToTargetClass)}(?:\\s|$)`, 'u')) &&
                    mutation.target.classList?.contains(appendToTargetClass)) {
                    // eslint-disable-next-line no-await-in-loop -- addViewer should ideally only happen once
                    await addViewer(mutation.target);
                }
            }
        }
    });

    observer.observe(document.body, {
        subtree: true,
        childList: true,
        attributeFilter: ['class'],
        attributeOldValue: true,
    });

    await addViewer(document.getElementsByClassName(appendToTargetClass)[0]);
})();