cohost view post source

adds a "view source" button to posts on cohost

// ==UserScript==
// @name         cohost view post source
// @namespace    https://github.com/adrianmgg
// @version      1.0.9
// @description  adds a "view source" button to posts on cohost
// @author       amgg
// @match        https://cohost.org/*
// @icon         https://cohost.org/static/a4f72033a674e35d4cc9.png
// @grant        unsafeWindow
// @run-at       document-start
// @compatible   firefox
// @compatible   chrome
// @license      MIT
// ==/UserScript==

(function() {

// =============================================================================

// not all userscript managers have unsafeWindow
const _unsafeWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;

// ======== https://github.com/adrianmgg/elhelper ========
function setup(elem,{style:{vars:styleVars={},...style}={},attrs={},dataset={},events={},classList=[],children=[],parent=null,insertBefore=null,...props}){for(const k in style)elem.style[k]=style[k];for(const k in styleVars)elem.style.setProperty(k,styleVars[k]);for(const k in attrs)elem.setAttribute(k,attrs[k]);for(const k in dataset)elem.dataset[k]=dataset[k];for(const k in events)elem.addEventListener(k,events[k]);for(const c of classList)elem.classList.add(c);for(const k in props)elem[k]=props[k];for(const c of children)elem.appendChild(c);if(parent!==null){if(insertBefore!==null)parent.insertBefore(elem,insertBefore);else parent.appendChild(elem);}return elem;}
function create(tagName,options={}){return setup(document.createElement(tagName),options);}
function createNS(namespace,tagName,options={}){return setup(document.createElementNS(namespace,tagName),options);}
// ======================================================

// =============================================================================
function observer_helper(filter, on_node_found, options) {
    const once = options?.once ?? false;
    const subtree = options?.subtree ?? false;
    return (target) => {
        for(const node of target.childNodes) {
            if(filter(node)) {
                on_node_found(node);
                if(once) return; // don't need to bother setting up the observer
            }
        }
        const observer = new MutationObserver((mutations, observer) => {
            for(const mutation of mutations) {
                if(mutation.type === 'childList') {
                    for(const node of mutation.addedNodes) {
                        if(filter(node)) {
                            on_node_found(node);
                            if(once) {
                                observer.disconnect();
                                return;
                            }
                        }
                    }
                }
            }
        });
        observer.observe(target, { childList: true, subtree: subtree });
    }
}
function observer_helper_chain(on_leaf_found, ...stuff) {
    let cur = observer_helper(stuff[stuff.length - 1][0], on_leaf_found, stuff[stuff.length - 1][1]);
    for(let i = stuff.length - 1 - 1; i >= 0; i--) {
        cur = observer_helper(stuff[i][0], cur, stuff[i][1]);
    }
    return cur;
}
function observer_helper_promise(target, filter, options) {
    return new Promise((resolve) => {
        observer_helper(filter, resolve, {...options, once: true})(target);
    });
}
// =============================================================================
function arrayEqualsStrict(a, b) {
    if(a === b) return true;
    if(a.length !== b.length) return false;
    for(let i = 0; i < a.length; i++) {
        if(a[i] !== b[i]) return false;
    }
    return true;
}
// =============================================================================

// ======== css stuff ========

function inject_stylesheet() {
    create('style', {
        parent: document.head,
        textContent: `
.amgg__viewsource__wrap-text .amgg__viewsource__source-view-container pre {
    white-space: pre-wrap;
    word-break: normal;
    overflow-wrap: anywhere;
}
.amgg__viewsource__post-hide-source-view .amgg__viewsource__source-view-container {
    display: none;
}
`,
    });
}

// ========= ========

function render_blocks_list(blocks) {
    const markdown_blocks = blocks.filter(block => block.type === 'markdown').map(block => block.markdown.content);
    if(markdown_blocks.length === 0) { return null; }
    return create('pre', {
        children: [create('code', {
            textContent: markdown_blocks.join('\n\n'),
        })],
    });
}

function handle_show_post_source_click(article, view_source_btn, post_id) {
    const post_data = post_id_to_post_data[post_id];
    if('alreadyRendered' in view_source_btn.dataset) {
        article.classList.toggle('amgg__viewsource__post-hide-source-view');
    } else {
        if(post_data === undefined) {
            alert(`data for post #${post_id} not found, unable to show source`);
        } else {
            // mark ourself as already having created the source view - any further clicks can just toggle its visibility instead
            view_source_btn.dataset.alreadyRendered = '';
            //
            create('div', {
                parent: article,
                classList: ['co-prose', 'prose', 'px-3', 'amgg__viewsource__source-view-container'],
                children: [
                    // "wrap text" checkbox
                    create('label', {
                        textContent: 'wrap text ',
                        children: [create('input', {
                            type: 'checkbox',
                            checked: false,
                            events: {
                                change: e => {
                                    article.classList[e.target.checked ? 'add' : 'remove']('amgg__viewsource__wrap-text');
                                },
                            },
                        })],
                    }),
                    // the posts
                    create('div', {
                        children: [...(post_data.shareTree.map(p=>p.blocks)), post_data.blocks].map(render_blocks_list).filter(x => x !== null && x !== undefined),
                    }),
                ],
            });
        }
    }
}

// ======== keeping track of post data ========

let post_id_to_post_data = {};
function handle_post_data(post) {
    post_id_to_post_data[post.postId] = post;
    if(post.transparentShareOfPostId !== undefined) {
        post_id_to_post_data[post.transparentShareOfPostId] = post;
    }
}

// ======== watching for new post elements ========


async function handle_post_element(post_elem) {
    // if we already put a view source button on this post we don't want to add a second one
    // if(post_elem.querySelector('.amgg__viewsource__view-source-button') !== null) {
    if('amggViewsourceDiscovered' in post_elem.dataset) {
        return;
    }
    post_elem.dataset.amggViewsourceDiscovered = '';
    // these have the post id in their id. the last one should be the one with the id
    //  for the current post (rather than another post further up in the thread)
    const post_id_elems = post_elem.querySelectorAll('[id^="post-"]');
    const post_id_elem = post_id_elems[post_id_elems.length - 1];
    const post_id = parseInt(post_id_elem.id.slice('post-'.length));
    const post_article = post_elem.querySelector(':scope > article');
    const post_footer = post_article.querySelector(':scope > footer > div');
    // set up the "show source" button
    const view_source_btn = create('div', {
        classList: ['amgg__viewsource__view-source-button'],
        children: [create('span', {
            textContent: 'view source',
            classList: ['cursor-pointer', 'pb-3', 'text-sm', 'font-bold', 'text-cherry', 'hover:underline'],
        })],
        events: {
            click: (e) => {
                handle_show_post_source_click(post_article, view_source_btn, post_id);
            },
        },
    });
    post_footer.insertBefore(view_source_btn, post_footer.childNodes[1]);
}

function attach_post_observer() {
    // TODO probably not the best performance-wise to observe mutations on the entire page,
    //  would be better to first identify the container posts live in, then just observe
    //  childList mutations on that container
    (new MutationObserver((mutations) => {
        for(const mutation of mutations) {
            if(mutation.type === 'childList') {
                for(const node of mutation.addedNodes) {
                    if(node.nodeType === Node.ELEMENT_NODE) {
                        if(node.getAttribute('data-view') === 'post-preview') {
                            handle_post_element(node);
                        }
                        node.querySelectorAll('[data-view="post-preview"]').forEach(handle_post_element);
                    }
                }
            }
        }
    })).observe(document, { subtree: true, childList: true });
    // for any that're already there before we start observing mutations
    document.querySelectorAll('[data-view="post-preview"]').forEach(handle_post_element);
}
if(document.readyState === 'interactive') {
    attach_post_observer();
} else {
    document.addEventListener('readystatechange', e => {
        if(document.readyState === 'interactive') {
            attach_post_observer();
        }
    });
}

// ======== handle the posts that are on the page initially ========

document.addEventListener('amgg__viewsource__foundpost', (e) => {
    handle_post_data(e.detail.post);
});
// (initially I did this by just grabbing the desired elements out of the head
//  as soon as they were present, but with longer data and a slower connection
//  that ended up trying to parse the json data before the content of the node
//  had fully loaded, which would fail. because of that, i switched to waiting
//  for the body to exist, since that should mean all the head nodes have been
//  fully downloaded)
observer_helper_chain(
    (body) => {
        inject_stylesheet();

        const dehydrated_state_elem = document.getElementById('trpc-dehydrated-state');
        if(dehydrated_state_elem !== null) {
            const dehydrated_state = JSON.parse(dehydrated_state_elem.textContent);
            for(const query of dehydrated_state.queries) {
                if(Array.isArray(query.queryKey) && Array.isArray(query.queryKey[0])) {
                    const queryPath = query.queryKey[0];
                    // TODO: does posts.byProject show up anywhere anymore? leaving it in for now just in case
                    if(arrayEqualsStrict(queryPath, ['posts', 'byProject']) || arrayEqualsStrict(queryPath, ['posts', 'profilePosts'])) {
                        // posts on someone's profile
                        query.state.data.posts.forEach(handle_post_data);
                    } else if(arrayEqualsStrict(queryPath, ['posts', 'singlePost'])) {
                        // viewing a single post
                        handle_post_data(query.state.data.post);
                    }
                }
            }
        }

        const cohost_loader_state_elem = document.getElementById('__COHOST_LOADER_STATE__');
        if(cohost_loader_state_elem !== null) {
            const cohost_loader_state = JSON.parse(cohost_loader_state_elem.textContent);
            // initial posts on home page (first page only)
            cohost_loader_state.dashboard?.posts?.forEach?.(handle_post_data);
            // posts on home but not first page
            // TODO - is project-post-feed used anymore?
            cohost_loader_state['project-post-feed']?.posts?.forEach?.(handle_post_data);
            cohost_loader_state['dashboard-nonlive-post-feed']?.posts?.forEach?.(handle_post_data);
            // posts on a tag search page
            cohost_loader_state['tagged-post-feed']?.posts?.forEach?.(handle_post_data);
            // bookmarks
            cohost_loader_state['bookmarked-tag-feed']?.posts?.forEach?.(handle_post_data);
            // per-user tag pages (/username/tagged/tag)
            cohost_loader_state['project-tagged-post-feed']?.posts?.forEach?.(handle_post_data);
        }
    },
    [n => n.nodeType === Node.ELEMENT_NODE && n.nodeName === 'HTML', {once: true}],
    [n => n.nodeType === Node.ELEMENT_NODE && n.nodeName === 'BODY', {once: true}],
)(document);


// ======== handle posts that get loaded later ========

// originally i was just getting the original functions from unsafeWindow,
// wrapping them, and replacing them, but that doesn't seem to work on
// greasemonkey, where the site's code errors out trying to call the wrapped
// function (i guess they do their sandboxing differently from tampermonkey &
// violentmonkey? idk). I don't actually need any privelaged usersript
// functions, so in theory i could `@grant none` and just not run sandboxed, but
// as far as i can tell the problem *still* happens on greasemonkey even then.
// doing it this way with events seems to work everywhere through, so that's
// what i'll go with
_unsafeWindow.eval(`
(() => {
function return_post_to_sandbox(post) {
    document.dispatchEvent(new CustomEvent('amgg__viewsource__foundpost', {
        detail: {
            post: post,
        },
    }));
}

const original_fetch = window.fetch;
window.fetch = function(resource, options) {
    if(resource?.constructor === String) {
        try {
            const url = new URL(resource, window.location.href);
            if(url.hostname === 'cohost.org' && url.pathname.startsWith('/api/v1/trpc/')) {
                const requested_things = url.pathname.slice('/api/v1/trpc/'.length).split(',');
                // TODO only need to bother wrapping it if one if the things we care about is in this fetch
                return original_fetch.apply(this, arguments).then(response => {
                    return new Promise(async (resolve) => {
                        // the requests get aborted if we give back the original response before we're done awaiting the .json() on the cloned response
                        const json = await response.clone().json();
                        for(const i in requested_things) {
                            // for posts viewed on user profiles
                            if(requested_things[i] === 'posts.byProject' || requested_things[i] === 'posts.profilePosts') {
                                json[i].result.data.posts.forEach(return_post_to_sandbox);
                            }
                        }
                        resolve(response);
                    });
                });
            }
        } catch(e) {
            console.error('view post source: error in fetch wrapper', e);
        }
    }
    return original_fetch.apply(this, arguments);
}

const original_EventSource = window.EventSource;
window.EventSource = function(...args) {
    const ret = new original_EventSource(...args);
    if(ret.url === 'https://cohost.org/rc/dashboard/event-stream') {
        ret.addEventListener('message', (e) => {
            const data = JSON.parse(e.data);
            data.add.forEach(return_post_to_sandbox);
        });
    }
    return ret;
};
})();
`);

})();